detached f40c90d

Merge pull request #108 from bnema/feat/accent-camaieu

feat(theme): add terminal accent camaïeu
commit
f40c90d9c7f8e4f7923d5dbf169d1c579d4f9d3e
author
bnema · · 2026-07-18 14:13 UTC
committer
GitHub · · 2026-07-18 14:13 UTC
merge of
fc3bb74 + a95a473 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
75 files · +5833 -1968
M cmd/vev-perf-harness/launcher.go +6 -3
@@ -628,9 +628,6 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
628628 func (p *cliProcess) Close() error {
629629 var result error
630630 p.closed.Do(func() {
631- if p.done != nil {
632- close(p.done)
633- }
634631 // A public terminal client owns a shell session. Ask that shell to exit
635632 // before closing the PTY so its transport can finish its receive span;
636633 // this avoids turning ordinary harness teardown into an aborted trace.
@@ -654,6 +651,12 @@ func (p *cliProcess) Close() error {
654651 case <-p.closeDeadline():
655652 }
656653 }
654+ // Keep draining terminal output while the client exits gracefully. If the
655+ // drain stops first, restoration output can fill the PTY and block the
656+ // client before it closes its observed transport receive operation.
657+ if p.done != nil {
658+ close(p.done)
659+ }
657660 if p.pty != nil {
658661 _ = p.pty.Close()
659662 }
M cmd/vev-perf-harness/launcher_test.go +49 -0
@@ -11,12 +11,61 @@ import (
1111 "os/exec"
1212 "path/filepath"
1313 "strings"
14+ "sync"
1415 "testing"
1516 "time"
1617
1718 "github.com/bnema/vev/pkg/safedir"
1819 )
1920
21+func TestCLIProcessCloseKeepsTerminalDrainActiveUntilClientExit(t *testing.T) {
22+ pty, err := os.CreateTemp(t.TempDir(), "pty")
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ waiting := make(chan struct{})
27+ deadline := make(chan time.Time)
28+ waitErr := make(chan error)
29+ done := make(chan struct{})
30+ closed := make(chan error, 1)
31+ var waitingOnce sync.Once
32+ p := &cliProcess{
33+ pty: pty,
34+ done: done,
35+ waitErr: waitErr,
36+ waitTimeout: func() <-chan time.Time {
37+ waitingOnce.Do(func() { close(waiting) })
38+ return deadline
39+ },
40+ }
41+ go func() { closed <- p.Close() }()
42+
43+ select {
44+ case <-waiting:
45+ case <-time.After(time.Second):
46+ t.Fatal("Close did not begin waiting for graceful client exit")
47+ }
48+ select {
49+ case <-done:
50+ t.Fatal("terminal drain stopped before the client exited")
51+ default:
52+ }
53+ waitErr <- nil
54+ select {
55+ case err := <-closed:
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ case <-time.After(time.Second):
60+ t.Fatal("Close did not finish after the client exited")
61+ }
62+ select {
63+ case <-done:
64+ default:
65+ t.Fatal("terminal drain remained active after client exit")
66+ }
67+}
68+
2069 func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
2170 deadline := make(chan time.Time)
2271 close(deadline)
M docs/configuration.md +11 -3
@@ -5,8 +5,10 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d
55 ```text
66 # Theme: auto follows the client; dark/light use neutral built-in defaults.
77 theme = auto
8-# In auto mode, inherit the terminal's ANSI blue for chrome accents.
8+# In auto mode with palette inheritance enabled, infer a terminal accent.
99 theme.palette = on
10+# Accent policy: auto or one exact ANSI slot number from 0 through 15.
11+theme.accent = auto
1012
1113 # Palette placement: auto, center, top-left, top, top-right, left, right,
1214 # bottom-left, bottom, or bottom-right.
@@ -66,11 +68,17 @@ code.rename-tab = RNT
6668 code.detach = DET
6769 ```
6870
69-Invalid values log a warning and keep the last valid value.
71+Invalid values log a warning and resolve that setting to its default on both initial load and reload.
7072
7173 ## Theme
7274
73-With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only.
75+With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; repeated terminal colors are preferred, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`.
76+
77+When the terminal provides truecolor defaults and a usable RGB palette slot, vev uses the resolved accent for active chrome and derives softer bar, inactive, recent-session, and border colors from it. It preserves readability by reducing a surface intensity when needed. Terminal application/pane colors are not recolored. Without truecolor default colors or a usable RGB resolved accent, chrome backgrounds remain neutral; an available selected ANSI slot can still decorate foregrounds or borders. An explicit unavailable slot is never replaced with another slot.
78+
79+Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning because conventional neutral slots may provide little or no accent separation. An invalid accent value logs a warning and falls back to `auto`, including on hot reload.
80+
81+`theme.palette = off` is authoritative: it keeps exact neutral foreground/background rendering and ignores `theme.accent`. Forced `theme = dark` or `theme = light` is also neutral and ignores both palette and accent policy. Configuration reload immediately reapplies the current terminal snapshot; vev updates a live palette when the terminal reports a light/dark scheme change, but cannot detect palette changes a terminal does not report.
7482
7583 ## Bindings
7684
M internal/adapters/config/config.go +6 -0
@@ -75,6 +75,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
7575 continue
7676 }
7777 cfg.ThemePalette = on
78+ case key == "theme.accent":
79+ accent, warning := parseThemeAccent(value)
80+ cfg.ThemeAccent = accent
81+ if warning != "" {
82+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
83+ }
7884 case key == "bar.top-right":
7985 command, ok := parseBarCommand(value)
8086 if !ok {
M internal/adapters/config/config_test.go +56 -98
@@ -7,12 +7,11 @@ import (
77 "path/filepath"
88 "reflect"
99 "strings"
10- "sync"
1110 "testing"
1211 "time"
1312
1413 "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1615 "github.com/stretchr/testify/require"
1716 )
1817
@@ -593,7 +592,7 @@ func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
593592 t.Fatal(err)
594593 }
595594
596- clk := newFakeClock(time.Unix(0, 0))
595+ clk := newWatchClockMock(t)
597596 ctx, cancel := context.WithCancel(context.Background())
598597 defer cancel()
599598
@@ -604,16 +603,16 @@ func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
604603 changes := make(chan change, 2)
605604 done := make(chan error, 1)
606605 go func() {
607- done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
606+ done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
608607 changes <- change{cfg: cfg, warnings: warnings}
609608 })
610609 }()
611610
612- clk.waitForTimers(t, 1)
611+ clk.waitForTimer()
613612 if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
614613 t.Fatal(err)
615614 }
616- clk.advance(2 * time.Second)
615+ clk.fire()
617616
618617 var got change
619618 select {
@@ -642,7 +641,7 @@ func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
642641 t.Fatal(err)
643642 }
644643
645- clk := newFakeClock(time.Unix(0, 0))
644+ clk := newWatchClockMock(t)
646645 ctx, cancel := context.WithCancel(context.Background())
647646 defer cancel()
648647
@@ -653,17 +652,17 @@ func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
653652 changes := make(chan change, 2)
654653 done := make(chan error, 1)
655654 go func() {
656- done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
655+ done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
657656 changes <- change{cfg: cfg, warnings: warnings}
658657 })
659658 }()
660659
661- clk.waitForTimers(t, 1)
660+ clk.waitForTimer()
662661 tooLong := strings.Repeat("x", 70*1024)
663662 if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
664663 t.Fatal(err)
665664 }
666- clk.advance(2 * time.Second)
665+ clk.fire()
667666
668667 got := <-changes
669668 require.Equal(t, domain.Defaults(), got.cfg)
@@ -679,23 +678,23 @@ func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
679678 t.Parallel()
680679
681680 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
682- clk := newFakeClock(time.Unix(0, 0))
681+ clk := newWatchClockMock(t)
683682 ctx, cancel := context.WithCancel(context.Background())
684683 defer cancel()
685684
686685 changes := make(chan []domain.Warning, 4)
687686 done := make(chan error, 1)
688687 go func() {
689- done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
688+ done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
690689 changes <- warnings
691690 })
692691 }()
693692
694693 first := <-changes
695694 require.Len(t, first, 1)
696- clk.waitForTimers(t, 1)
697- clk.advance(2 * time.Second)
698- clk.advance(2 * time.Second)
695+ clk.waitForTimer()
696+ clk.fire()
697+ clk.fire()
699698
700699 select {
701700 case extra := <-changes:
@@ -709,96 +708,55 @@ func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
709708 }
710709 }
711710
712-var _ ports.Clock = (*fakeClock)(nil)
713-var _ ports.Timer = (*fakeTimer)(nil)
714-
715-type fakeClock struct {
716- mu sync.Mutex
717- now time.Time
718- timers []*fakeTimer
719- notify chan struct{}
720-}
721-
722-func newFakeClock(now time.Time) *fakeClock {
723- return &fakeClock{now: now, notify: make(chan struct{}, 16)}
724-}
725-
726-func (c *fakeClock) Now() time.Time {
727- c.mu.Lock()
728- defer c.mu.Unlock()
729- return c.now
730-}
731-
732-func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
733- c.mu.Lock()
734- defer c.mu.Unlock()
735-
736- t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
737- c.timers = append(c.timers, t)
738- c.notify <- struct{}{}
739- return t
711+type watchClockMock struct {
712+ t *testing.T
713+ clock *portsmocks.MockClock
714+ ticks chan time.Time
715+ ready chan struct{}
716+ resets chan struct{}
740717 }
741718
742-func (c *fakeClock) waitForTimers(t *testing.T, n int) {
719+func newWatchClockMock(t *testing.T) *watchClockMock {
743720 t.Helper()
744- deadline := time.After(2 * time.Second)
745- for c.timerCount() < n {
746- select {
747- case <-c.notify:
748- case <-deadline:
749- t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
750- }
721+ timer := portsmocks.NewMockTimer(t)
722+ clock := portsmocks.NewMockClock(t)
723+ m := &watchClockMock{
724+ t: t,
725+ clock: clock,
726+ ticks: make(chan time.Time),
727+ ready: make(chan struct{}),
728+ resets: make(chan struct{}),
751729 }
730+ timer.EXPECT().C().Return(m.ticks)
731+ timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
732+ m.resets <- struct{}{}
733+ }).Return(false)
734+ timer.EXPECT().Stop().Return(true)
735+ clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
736+ close(m.ready)
737+ }).Return(timer).Once()
738+ return m
752739 }
753740
754-func (c *fakeClock) timerCount() int {
755- c.mu.Lock()
756- defer c.mu.Unlock()
757- return len(c.timers)
758-}
759-
760-func (c *fakeClock) advance(d time.Duration) {
761- c.mu.Lock()
762- defer c.mu.Unlock()
763-
764- c.now = c.now.Add(d)
765- for _, timer := range c.timers {
766- timer.mu.Lock()
767- if timer.active && !timer.next.After(c.now) {
768- timer.ch <- c.now
769- timer.active = false
770- }
771- timer.mu.Unlock()
741+func (m *watchClockMock) waitForTimer() {
742+ m.t.Helper()
743+ select {
744+ case <-m.ready:
745+ case <-time.After(time.Second):
746+ m.t.Fatal("timed out waiting for watcher timer creation")
772747 }
773748 }
774749
775-type fakeTimer struct {
776- clock *fakeClock
777- mu sync.Mutex
778- ch chan time.Time
779- next time.Time
780- active bool
781-}
782-
783-func (t *fakeTimer) C() <-chan time.Time { return t.ch }
784-
785-func (t *fakeTimer) Reset(d time.Duration) bool {
786- t.clock.mu.Lock()
787- defer t.clock.mu.Unlock()
788- t.mu.Lock()
789- defer t.mu.Unlock()
790-
791- wasActive := t.active
792- t.active = true
793- t.next = t.clock.now.Add(d)
794- return wasActive
795-}
796-
797-func (t *fakeTimer) Stop() bool {
798- t.mu.Lock()
799- defer t.mu.Unlock()
800-
801- wasActive := t.active
802- t.active = false
803- return wasActive
750+func (m *watchClockMock) fire() {
751+ m.t.Helper()
752+ select {
753+ case m.ticks <- time.Now():
754+ case <-time.After(time.Second):
755+ m.t.Fatal("timed out firing watcher timer")
756+ }
757+ select {
758+ case <-m.resets:
759+ case <-time.After(time.Second):
760+ m.t.Fatal("timed out waiting for watcher timer reset")
761+ }
804762 }
M internal/adapters/config/palette_anchor_test.go +7 -7
@@ -6,7 +6,6 @@ import (
66 "path/filepath"
77 "strings"
88 "testing"
9- "time"
109
1110 "github.com/bnema/vev/internal/domain"
1211 "github.com/stretchr/testify/require"
@@ -50,21 +49,22 @@ func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) {
5049 func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) {
5150 path := filepath.Join(t.TempDir(), "config")
5251 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
53- clk := newFakeClock(time.Unix(0, 0))
52+ clk := newWatchClockMock(t)
5453 ctx, cancel := context.WithCancel(context.Background())
5554 defer cancel()
5655 changes := make(chan domain.Config, 2)
5756 done := make(chan error, 1)
58- go func() { done <- Watch(ctx, clk, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) }()
59- clk.waitForTimers(t, 1)
57+ go func() {
58+ done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg })
59+ }()
60+ clk.waitForTimer()
6061
6162 require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
62- clk.advance(2 * time.Second)
63+ clk.fire()
6364 require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
6465
6566 require.NoError(t, os.Remove(path))
66- clk.waitForTimers(t, 1)
67- clk.advance(2 * time.Second)
67+ clk.fire()
6868 got := <-changes
6969 require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette)
7070 cancel()
A internal/adapters/config/theme_accent.go +27 -0
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strconv"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func parseThemeAccent(value string) (domain.ThemeAccent, string) {
11+ if value == "auto" {
12+ return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, ""
13+ }
14+
15+ slot, err := strconv.Atoi(value)
16+ if err != nil || slot < 0 || slot > 15 || strconv.Itoa(slot) != value {
17+ return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, fmt.Sprintf("invalid theme.accent %q", value)
18+ }
19+
20+ accent := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: uint8(slot)}
21+ switch slot {
22+ case 0, 7, 8, 15:
23+ return accent, fmt.Sprintf("theme.accent slot %d is conventionally neutral", slot)
24+ default:
25+ return accent, ""
26+ }
27+}
A internal/adapters/config/theme_accent_test.go +155 -0
@@ -0,0 +1,155 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestParseThemeAccent(t *testing.T) {
17+ t.Parallel()
18+
19+ tests := []struct {
20+ name string
21+ input string
22+ want domain.ThemeAccent
23+ warnings []domain.Warning
24+ }{
25+ {
26+ name: "auto",
27+ input: "theme.accent = auto\n",
28+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
29+ },
30+ {
31+ name: "slot zero is valid but warns",
32+ input: "theme.accent = 0\n",
33+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0},
34+ warnings: []domain.Warning{
35+ {Line: 1, Msg: "theme.accent slot 0 is conventionally neutral"},
36+ },
37+ },
38+ {
39+ name: "slot fifteen is valid but warns",
40+ input: "theme.accent = 15\n",
41+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15},
42+ warnings: []domain.Warning{
43+ {Line: 1, Msg: "theme.accent slot 15 is conventionally neutral"},
44+ },
45+ },
46+ {
47+ name: "chromatic slot",
48+ input: "theme.accent = 2\n",
49+ want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2},
50+ },
51+ {
52+ name: "negative slot falls back to auto",
53+ input: "theme.accent = -1\n",
54+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
55+ warnings: []domain.Warning{
56+ {Line: 1, Msg: "invalid theme.accent \"-1\""},
57+ },
58+ },
59+ {
60+ name: "out of range slot falls back to auto",
61+ input: "theme.accent = 16\n",
62+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
63+ warnings: []domain.Warning{
64+ {Line: 1, Msg: "invalid theme.accent \"16\""},
65+ },
66+ },
67+ {
68+ name: "non numeric value falls back to auto",
69+ input: "theme.accent = teal\n",
70+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
71+ warnings: []domain.Warning{
72+ {Line: 1, Msg: "invalid theme.accent \"teal\""},
73+ },
74+ },
75+ {
76+ name: "non canonical slot falls back to auto",
77+ input: "theme.accent = 02\n",
78+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
79+ warnings: []domain.Warning{
80+ {Line: 1, Msg: "invalid theme.accent \"02\""},
81+ },
82+ },
83+ {
84+ name: "case sensitive auto falls back to auto",
85+ input: "theme.accent = AUTO\n",
86+ want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
87+ warnings: []domain.Warning{
88+ {Line: 1, Msg: "invalid theme.accent \"AUTO\""},
89+ },
90+ },
91+ }
92+
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ t.Parallel()
96+
97+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
98+ require.NoError(t, err)
99+ require.Equal(t, tt.want, cfg.ThemeAccent)
100+ require.Equal(t, tt.warnings, warnings)
101+ })
102+ }
103+}
104+
105+func TestDefaultsThemeAccent(t *testing.T) {
106+ t.Parallel()
107+
108+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, domain.Defaults().ThemeAccent)
109+}
110+
111+func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) {
112+ t.Parallel()
113+
114+ path := filepath.Join(t.TempDir(), "config")
115+ require.NoError(t, os.WriteFile(path, []byte("theme.accent = 2\n"), 0o600))
116+ initial, warnings, err := Load(path)
117+ require.NoError(t, err)
118+ require.Empty(t, warnings)
119+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)
120+
121+ clk := newWatchClockMock(t)
122+ ctx, cancel := context.WithCancel(context.Background())
123+ defer cancel()
124+ type change struct {
125+ cfg domain.Config
126+ warnings []domain.Warning
127+ }
128+ changes := make(chan change, 1)
129+ done := make(chan error, 1)
130+ go func() {
131+ done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
132+ changes <- change{cfg: cfg, warnings: warnings}
133+ })
134+ }()
135+
136+ clk.waitForTimer()
137+ require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))
138+ clk.fire()
139+
140+ select {
141+ case got := <-changes:
142+ require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
143+ require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
144+ case <-time.After(2 * time.Second):
145+ t.Fatal("timed out waiting for config reload")
146+ }
147+
148+ cancel()
149+ select {
150+ case err := <-done:
151+ require.True(t, errors.Is(err, context.Canceled))
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("timed out waiting for config watcher shutdown")
154+ }
155+}
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
8989
9090 select {
9191 case got := <-captured:
92- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
92+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
9393 if string(got) != want {
9494 t.Fatalf("captured escapes = %q, want %q", got, want)
9595 }
M internal/adapters/term/terminal.go +1 -18
@@ -31,9 +31,6 @@ const (
3131 bracketedPasteDisable = "\x1b[?2004l"
3232 colorSchemeEnable = "\x1b[?2031h"
3333 colorSchemeDisable = "\x1b[?2031l"
34- oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
35- oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
36- oscColorQuery = oscDefaultColorQuery + oscPaletteQuery
3734 )
3835
3936 // bufSize is the batched writer's buffer capacity.
@@ -110,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
110107 }
111108 t.orig = old
112109
113- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {
110+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
114111 _ = t.restoreRawLocked()
115112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
116113 }
@@ -237,20 +234,6 @@ func (t *Terminal) stopResizeLocked() {
237234 t.resizeWG.Wait()
238235 }
239236
240-// QueryColors re-emits OSC 10/11 queries for default foreground/background
241-// followed by one batched OSC 4 query for palette slots 0 through 15.
242-func (t *Terminal) QueryColors() error {
243- t.mu.Lock()
244- defer t.mu.Unlock()
245- if _, err := t.bw.WriteString(oscColorQuery); err != nil {
246- return fmt.Errorf("term: query colors: %w", err)
247- }
248- if err := t.bw.Flush(); err != nil {
249- return fmt.Errorf("term: query colors: %w", err)
250- }
251- return nil
252-}
253-
254237 // In returns the reader for the controlling terminal's input.
255238 func (t *Terminal) In() io.Reader {
256239 return t.in
M internal/adapters/term/terminal_test.go +7 -29
@@ -10,8 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 )
1212
13-const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
14-
1513 // pipePair returns an os.Pipe and a goroutine that continuously copies
1614 // everything written to the write end into captured, closing done once
1715 // the write end is closed (EOF).
@@ -62,10 +60,13 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6260 _ = outW.Close()
6361 <-done
6462
65- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
6664 if got := captured.String(); got != want {
6765 t.Fatalf("captured escapes = %q, want %q", got, want)
6866 }
67+ if got := captured.String(); bytes.Contains([]byte(got), []byte("\x1b]10;?")) || bytes.Contains([]byte(got), []byte("\x1b]4;")) {
68+ t.Fatalf("EnterRaw emitted a color query: %q", got)
69+ }
6970 }
7071
7172 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
@@ -100,37 +101,14 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
100101 _ = outW.Close()
101102 <-done
102103
103- // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
104- // cursor/alt-screen exits exactly once, regardless of how many times
105- // EnterRaw/restore were called.
106- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
104+ // Alt-screen/cursor escapes must appear exactly once for enter and exits
105+ // exactly once, regardless of how many times EnterRaw/restore were called.
106+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
107107 if got := captured.String(); got != want {
108108 t.Fatalf("captured escapes = %q, want %q", got, want)
109109 }
110110 }
111111
112-func TestTerminal_QueryColors(t *testing.T) {
113- inR, inW, err := os.Pipe()
114- if err != nil {
115- t.Fatalf("os.Pipe(in): %v", err)
116- }
117- defer func() { _ = inW.Close() }()
118- defer func() { _ = inR.Close() }()
119-
120- outR, outW, captured, done := pipeCapture(t)
121- defer func() { _ = outR.Close() }()
122-
123- tm := NewWithFiles(inR, outW)
124- if err := tm.QueryColors(); err != nil {
125- t.Fatalf("QueryColors: %v", err)
126- }
127- _ = outW.Close()
128- <-done
129- if got := captured.String(); got != wantColorQueries {
130- t.Fatalf("captured = %q, want %q", got, wantColorQueries)
131- }
132-}
133-
134112 func TestTerminal_InOut(t *testing.T) {
135113 inR, inW, err := os.Pipe()
136114 if err != nil {
M internal/domain/config.go +2 -0
@@ -82,6 +82,7 @@ type TabsConfig struct {
8282 type Config struct {
8383 Theme ThemeMode
8484 ThemePalette bool
85+ ThemeAccent ThemeAccent
8586 Bar BarConfig
8687 BindingEntries []ConfigEntry
8788 Codes map[string]string
@@ -104,6 +105,7 @@ func Defaults() Config {
104105 return Config{
105106 Theme: ThemeAuto,
106107 ThemePalette: true,
108+ ThemeAccent: ThemeAccent{Mode: ThemeAccentAuto},
107109 Bar: BarConfig{
108110 Interval: 5 * time.Second,
109111 },
A internal/domain/theme_accent.go +19 -0
@@ -0,0 +1,19 @@
1+package domain
2+
3+// ThemeAccentMode selects whether vev infers a terminal accent or uses one
4+// explicit ANSI slot.
5+type ThemeAccentMode uint8
6+
7+const (
8+ // ThemeAccentAuto infers an accent from the terminal palette.
9+ ThemeAccentAuto ThemeAccentMode = iota
10+ // ThemeAccentSlot selects one explicit ANSI palette slot.
11+ ThemeAccentSlot
12+)
13+
14+// ThemeAccent is the parsed terminal accent policy. Slot is meaningful only
15+// when Mode is ThemeAccentSlot.
16+type ThemeAccent struct {
17+ Mode ThemeAccentMode
18+ Slot uint8
19+}
M internal/ports/mocks/terminal_mock.go +0 -44
@@ -229,50 +229,6 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
229229 return _c
230230 }
231231
232-// QueryColors provides a mock function for the type MockTerminal
233-func (_mock *MockTerminal) QueryColors() error {
234- ret := _mock.Called()
235-
236- if len(ret) == 0 {
237- panic("no return value specified for QueryColors")
238- }
239-
240- var r0 error
241- if returnFunc, ok := ret.Get(0).(func() error); ok {
242- r0 = returnFunc()
243- } else {
244- r0 = ret.Error(0)
245- }
246- return r0
247-}
248-
249-// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
250-type MockTerminal_QueryColors_Call struct {
251- *mock.Call
252-}
253-
254-// QueryColors is a helper method to define mock.On call
255-func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
256- return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
257-}
258-
259-func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
260- _c.Call.Run(func(args mock.Arguments) {
261- run()
262- })
263- return _c
264-}
265-
266-func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
267- _c.Call.Return(err)
268- return _c
269-}
270-
271-func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
272- _c.Call.Return(run)
273- return _c
274-}
275-
276232 // ResizeEvents provides a mock function for the type MockTerminal
277233 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
278234 ret := _mock.Called()
M internal/ports/terminal.go +0 -1
@@ -11,7 +11,6 @@ type Terminal interface {
1111 EnterRaw() (restore func() error, err error)
1212 Size() (domain.Size, error)
1313 ResizeEvents() <-chan domain.Size
14- QueryColors() error
1514 In() io.Reader
1615 Out() io.Writer
1716 Flush() error
M internal/ports/wire_test.go +17 -0
@@ -321,6 +321,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
321321 assertTrailingGarbageFails(t, full, UnmarshalTheme)
322322 }
323323
324+func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) {
325+ // A generation clear retains defaults/capabilities but has no palette bits.
326+ // This literal payload locks the existing 57-byte Theme layout while the
327+ // full definitive-palette golden above locks the final publication.
328+ cleared := Theme{
329+ HasForeground: true,
330+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
331+ HasBackground: true,
332+ Background: renderer.RGB{R: 4, G: 5, B: 6},
333+ TrueColor: true,
334+ SchemeKnown: true,
335+ }
336+ want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337+ require.Equal(t, want, MarshalTheme(cleared))
338+ require.Equal(t, uint16(16), ProtocolVersion)
339+}
340+
324341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
325342 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
326343 want := []byte{0x00, 0x64, 0x00, 0x28}
M internal/usecase/client/client.go +691 -260
@@ -77,20 +77,6 @@ func (s *terminalThemeState) snapshot() ports.Theme {
7777 return s.theme
7878 }
7979
80-// clearPalette invalidates entries from a previous terminal color query while
81-// retaining independently reported foreground, background, and scheme data.
82-func (s *terminalThemeState) clearPalette() ports.Theme {
83- return s.update(func(theme *ports.Theme) {
84- theme.PaletteKnown = 0
85- theme.Palette = [16]renderer.RGB{}
86- })
87-}
88-
89-func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
90- theme := s.snapshot()
91- return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0
92-}
93-
9480 // milestones tracks lifecycle progress so a failure can report which stages
9581 // were never reached — the key diagnostic when an attach dies early.
9682 type milestones struct {
@@ -181,6 +167,9 @@ type Runner struct {
181167 clipboard ports.ClipboardReader
182168 logger *slog.Logger
183169 runtimeObserver ports.SerializedRuntimeObserver
170+
171+ inputMu sync.Mutex
172+ input *terminalInputPump
184173 }
185174
186175 // NewRunner constructs a client runner. A nil logger uses the process default.
@@ -199,6 +188,21 @@ func NewRunner(deps Dependencies) *Runner {
199188 }
200189 }
201190
191+// terminalInput returns the Runner-owned sole reader. It survives sequential
192+// Run calls because a caller-owned reader may remain blocked after an attach
193+// exits and therefore cannot safely be replaced by another reader.
194+func (r *Runner) terminalInput() *terminalInputPump {
195+ r.inputMu.Lock()
196+ defer r.inputMu.Unlock()
197+
198+ if r.input == nil || r.input.hasExited() || r.input.isClosed() {
199+ r.input = newTerminalInputPump(r.term.In())
200+ r.input.start()
201+ }
202+ r.input.resume()
203+ return r.input
204+}
205+
202206 // Run connects and runs the attach client. It owns the terminal lifecycle
203207 // above attach attempts so raw mode remains active while a live client process
204208 // redials a lost link.
@@ -244,6 +248,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
244248 rawEntered: &rawEntered,
245249 stage: reconnectStageOfflineRetrying,
246250 }
251+ var input *terminalInputPump
252+ defer func() {
253+ if input != nil {
254+ input.suspend()
255+ }
256+ }()
257+ originalEnterRaw := enterRaw
258+ enterRaw = func() error {
259+ if err := originalEnterRaw(); err != nil {
260+ return err
261+ }
262+ if input == nil {
263+ input = r.terminalInput()
264+ }
265+ return nil
266+ }
247267
248268 for {
249269 transport, err := r.dialer.Dial(ctx)
@@ -279,6 +299,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
279299 enterRaw: enterRaw,
280300 reconnect: reconnect,
281301 linkEvents: linkEvents,
302+ terminalInput: func() *terminalInputPump {
303+ return input
304+ },
282305 }).run(ctx)
283306 if result.welcomed {
284307 backoff = defaultReconnectBackoff.initial
@@ -418,16 +441,17 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
418441 }
419442
420443 type attachAttempt struct {
421- runner *Runner
422- transport ports.Transport
423- request AttachRequest
424- resumeToken uint64
425- clientID [16]byte
426- milestones *milestones
427- themeState *terminalThemeState
428- enterRaw func() error
429- reconnect *reconnectUI
430- linkEvents <-chan ports.LinkEvent
444+ runner *Runner
445+ transport ports.Transport
446+ request AttachRequest
447+ resumeToken uint64
448+ clientID [16]byte
449+ milestones *milestones
450+ themeState *terminalThemeState
451+ enterRaw func() error
452+ reconnect *reconnectUI
453+ linkEvents <-chan ports.LinkEvent
454+ terminalInput func() *terminalInputPump
431455 }
432456
433457 type attachResult struct {
@@ -479,6 +503,16 @@ func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Tra
479503 }
480504 }
481505
506+// paletteSlot validates the scanner's signed slot before narrowing it for the
507+// wire accumulator. Keep this boundary even though the in-tree Scanner also
508+// validates slots: callbacks are an interface boundary.
509+func paletteSlot(slot int) (uint8, bool) {
510+ if slot < 0 || slot > 15 {
511+ return 0, false
512+ }
513+ return uint8(slot), true
514+}
515+
482516 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
483517 func DetectTrueColor(termEnv, colorTerm string) bool {
484518 switch strings.ToLower(strings.TrimSpace(colorTerm)) {
@@ -586,50 +620,160 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
586620 return welcomedResult(err)
587621 }
588622 reconnect.clear()
589- reportedTheme, restoreTheme := themeState.reportedTheme()
590623
591624 // 4. Derive a cancellable context so the pumps always unwind when the
592- // loop returns. cancel runs first (LIFO): it signals the pumps; the
593- // deferred Close above then unblocks any pump parked in a syscall.
625+ // loop returns. The attach loop is the sole terminal writer and palette
626+ // coordinator owner.
594627 loopCtx, cancel := context.WithCancel(ctx)
595628 defer cancel()
629+ input := (*terminalInputPump)(nil)
630+ if a.terminalInput != nil {
631+ input = a.terminalInput()
632+ }
633+ ownedInput := input == nil
634+ if ownedInput {
635+ input = newTerminalInputPump(term.In())
636+ input.start()
637+ defer input.stop()
638+ }
639+ inputConsumer := input.claim()
640+ claimActive := true
641+ revokeInputClaim := func() {
642+ if claimActive {
643+ input.revoke(inputConsumer)
644+ claimActive = false
645+ }
646+ }
647+ // Register revocation before any palette publication or terminal I/O: both
648+ // can fail before the stdin scanner is started, and a reconnect must still
649+ // be able to claim this lifecycle-owned reader.
650+ defer revokeInputClaim()
596651
597652 sendCh := make(chan ports.Frame, sendQueueDepth)
598- if restoreTheme {
599- sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)}
600- }
601653 ackQueue := newCumulativeAckQueue()
602654 sendErrCh := make(chan error, 1)
603-
604- // Palette re-queries use a private-mode query as an ordered boundary. The
605- // stdin pump drains terminal replies through its mode-specific response
606- // before it permits the main loop to publish the cleared scheme and issue
607- // QueryColors. This separates bytes already queued (including
608- // Scanner.pending) from replacement replies without allowing a second
609- // goroutine to write to the terminal.
610- colorQueryCh := make(chan colorQueryRequest)
611- paletteReadyCh := make(chan paletteQueryReady)
612- requestColors := func() (colorQueryRequest, bool) {
613- request := colorQueryRequest{boundaryWritten: make(chan struct{})}
614- select {
615- case colorQueryCh <- request:
616- case <-loopCtx.Done():
617- return colorQueryRequest{}, false
655+ senderStarted := false
656+ paletteEvents := make(chan paletteGenerationEvent, 32)
657+ var activeGeneration atomic.Uint64
658+ coordinator := newPaletteGenerationCoordinator()
659+ type paletteTimer struct {
660+ timer ports.Timer
661+ cancel chan struct{}
662+ }
663+ drainTimers := map[paletteGenerationID]paletteTimer{}
664+ completionTimers := map[paletteGenerationID]paletteTimer{}
665+ cancelTimer := func(timers map[paletteGenerationID]paletteTimer, id paletteGenerationID) {
666+ if timer, ok := timers[id]; ok {
667+ timer.timer.Stop()
668+ close(timer.cancel)
669+ delete(timers, id)
670+ }
671+ }
672+ publish := func(snapshot ports.Theme) error {
673+ frame := ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}
674+ // The initial cleared snapshot is sent before the sender exists. This
675+ // keeps the generation publication ordered before the first query and
676+ // avoids leaving a queued Theme behind an immediate detach.
677+ if !senderStarted {
678+ if err := transport.Send(frame); err != nil {
679+ return fmt.Errorf("sending initial theme: %w", err)
680+ }
681+ return nil
618682 }
619683 select {
620- case <-request.boundaryWritten:
621- return request, true
684+ case sendCh <- frame:
685+ return nil
622686 case <-loopCtx.Done():
623- return colorQueryRequest{}, false
687+ return context.Canceled
624688 }
625689 }
690+ processPaletteActions := func(actions []paletteGenerationAction) error {
691+ for _, action := range actions {
692+ switch action.kind {
693+ case paletteActionPublishCleared:
694+ if err := publish(action.theme); err != nil {
695+ return fmt.Errorf("publishing theme: %w", err)
696+ }
697+ case paletteActionPublishFinal:
698+ themeState.update(func(current *ports.Theme) { *current = action.theme })
699+ if err := publish(action.theme); err != nil {
700+ return fmt.Errorf("publishing theme: %w", err)
701+ }
702+ case paletteActionWriteDrain, paletteActionWriteBatch:
703+ if _, err := term.Out().Write([]byte(action.bytes)); err != nil {
704+ return fmt.Errorf("writing palette query: %w", err)
705+ }
706+ if err := term.Flush(); err != nil {
707+ return fmt.Errorf("flushing palette query: %w", err)
708+ }
709+ case paletteActionArmDrainDeadline, paletteActionArmCompletionDeadline:
710+ timer := clk.NewTimer(action.deadline)
711+ entry := paletteTimer{timer: timer, cancel: make(chan struct{})}
712+ timers := drainTimers
713+ kind := paletteEventDrainDeadline
714+ if action.kind == paletteActionArmCompletionDeadline {
715+ timers = completionTimers
716+ kind = paletteEventCompletionDeadline
717+ }
718+ timers[action.id] = entry
719+ go func(id paletteGenerationID, eventKind paletteGenerationEventKind, timer paletteTimer) {
720+ select {
721+ case <-timer.timer.C():
722+ select {
723+ case paletteEvents <- paletteGenerationEvent{id: id, kind: eventKind}:
724+ case <-loopCtx.Done():
725+ }
726+ case <-timer.cancel:
727+ case <-loopCtx.Done():
728+ }
729+ }(action.id, kind, entry)
730+ case paletteActionCancelDrainDeadline:
731+ cancelTimer(drainTimers, action.id)
732+ case paletteActionCancelCompletionDeadline:
733+ cancelTimer(completionTimers, action.id)
734+ }
735+ }
736+ return nil
737+ }
738+ defer func() {
739+ for id := range drainTimers {
740+ cancelTimer(drainTimers, id)
741+ }
742+ for id := range completionTimers {
743+ cancelTimer(completionTimers, id)
744+ }
745+ }()
746+
747+ // Initial acquisition is a generation too. A reconnect publishes its one
748+ // cleared snapshot from retained definitive colors, never a restore frame.
749+ retained := themeState.snapshot()
750+ initialActions := coordinator.start(retained, false)
751+ activeGeneration.Store(uint64(coordinator.current.id))
752+ if err := processPaletteActions(initialActions); err != nil {
753+ return welcomedResult(err)
754+ }
626755
627756 var clip ports.ClipboardReader
628757 if remote {
629758 clip = clipboard
630759 }
760+ senderStarted = true
631761 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
632- go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run()
762+ stdinDone := make(chan struct{})
763+ go func() {
764+ defer close(stdinDone)
765+ (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
766+ }()
767+ // Scanner cancellation can leave an undecided marker suffix (including a
768+ // standalone Escape) that it must hand back to the lifecycle-owned reader.
769+ // Wait for that handoff before revoking the claim: otherwise a replacement
770+ // scanner could claim the reader between revocation and preservation and
771+ // lose the suffix.
772+ defer func() {
773+ cancel()
774+ <-stdinDone
775+ revokeInputClaim()
776+ }()
633777 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
634778
635779 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -668,29 +812,22 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
668812 // token. Run closes this transport on the way out.
669813 return welcomedResult(errLinkOffline)
670814 }
671- case request := <-colorQueryCh:
672- if _, err := term.Out().Write(paletteBoundaryQuery); err != nil {
673- return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674- }
675- if err := term.Flush(); err != nil {
676- return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))
677- }
678- close(request.boundaryWritten)
679- case ready := <-paletteReadyCh:
680- // The private-mode response proves all earlier replies have been
681- // discarded by stdin. Publish the one cleared scheme snapshot before
682- // emitting the query; stdin remains paused until QueryColors has
683- // completed below.
684- clearedTheme := themeState.snapshot()
685- select {
686- case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
687- case <-loopCtx.Done():
688- return welcomedResult(nil)
815+ case event := <-paletteEvents:
816+ if event.kind == paletteEventScheme {
817+ retained := themeState.update(func(current *ports.Theme) {
818+ current.SchemeKnown = true
819+ current.Light = event.light
820+ })
821+ actions := coordinator.start(retained, true)
822+ activeGeneration.Store(uint64(coordinator.current.id))
823+ if err := processPaletteActions(actions); err != nil {
824+ return welcomedResult(err)
825+ }
826+ continue
689827 }
690- if err := term.QueryColors(); err != nil {
691- log.Warn("querying terminal colors", "err", err)
828+ if err := processPaletteActions(coordinator.handle(event)); err != nil {
829+ return welcomedResult(err)
692830 }
693- close(ready.queryIssued)
694831 case r := <-recvCh:
695832 if r.err != nil {
696833 if errors.Is(r.err, io.EOF) {
@@ -838,160 +975,397 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
838975 }
839976 }
840977
841-// stdinPump pumps terminal input to the daemon as Input frames. A read error
842-// or EOF is treated as a detach: it best-effort sends a Detach frame and
843-// cancels the loop.
844-//
845-// Known MVP limitation: a bare io.Reader cannot be unblocked from outside,
846-// so on shutdown initiated elsewhere (daemon detach, transport loss) this
847-// goroutine stays parked in Read until the next byte arrives or the process
848-// exits. That is harmless here — Run has already returned and restored the
849-// terminal — and matches the standard pattern for stdin pumps; a
850-// closable stdin duplicate could lift it later if ever needed.
851-type colorQueryRequest struct {
852- boundaryWritten chan struct{}
978+// terminalReadResult is copied by terminalInputPump before the next Read so
979+// terminal input remains owned by one lifecycle reader across reconnects.
980+type terminalReadResult struct {
981+ data []byte
982+ err error
853983 }
854984
855-type paletteQueryReady struct {
856- queryIssued chan struct{}
985+// terminalInputPump is the sole consumer of the caller-owned terminal reader.
986+// It is started once after raw mode is entered and is reused by each attach
987+// scanner. A bare io.Reader cannot be interrupted, so a Read may stay blocked
988+// after Run exits; suspend drops any result received while inactive and never
989+// closes caller-owned stdin. Reconnects and later runs never add another reader.
990+type terminalInputPump struct {
991+ in io.Reader
992+ done chan struct{}
993+
994+ mu sync.Mutex
995+ // residual holds scanner bytes that were read but not delivered when an
996+ // attempt is cancelled. It is consumed before the next terminal Read so a
997+ // replacement scanner neither loses a partial marker nor replays bytes
998+ // whose callbacks already ran.
999+ residual []byte
1000+ pending *terminalReadResult
1001+ consumer uint64
1002+ delivering uint64
1003+ deliveringResidual bool
1004+ nextID uint64
1005+ activation uint64
1006+ closed bool
1007+ active bool
1008+ ready chan struct{}
1009+ space chan struct{}
1010+ state chan struct{}
1011+ exited chan struct{}
1012+ afterRevoke func() // test synchronization hook
1013+ startMu sync.Once
1014+ stopMu sync.Once
8571015 }
8581016
859-type paletteDrain struct {
860- pending []byte
1017+func newTerminalInputPump(in io.Reader) *terminalInputPump {
1018+ space := make(chan struct{}, 1)
1019+ space <- struct{}{}
1020+ return &terminalInputPump{
1021+ in: in,
1022+ done: make(chan struct{}),
1023+ activation: 1,
1024+ active: true,
1025+ ready: make(chan struct{}, 1),
1026+ space: space,
1027+ state: make(chan struct{}, 1),
1028+ exited: make(chan struct{}),
1029+ }
8611030 }
8621031
863-type palettePhase uint8
1032+// claim grants one attach scanner exclusive permission to dequeue terminal
1033+// input. The reader itself remains lifecycle-owned across reconnects.
1034+func (p *terminalInputPump) claim() uint64 {
1035+ p.mu.Lock()
1036+ defer p.mu.Unlock()
1037+ if p.consumer != 0 {
1038+ panic("terminal input consumer already claimed")
1039+ }
1040+ p.nextID++
1041+ p.consumer = p.nextID
1042+ return p.consumer
1043+}
8641044
865-const (
866- paletteAccepting palettePhase = iota
867- paletteBoundaryRequested
868- paletteDraining
869- paletteAwaitingQuery
870-)
1045+// revoke invalidates an attempt before its replacement is allowed to claim
1046+// input. Pending bytes are deliberately retained for that replacement.
1047+func (p *terminalInputPump) revoke(consumer uint64) {
1048+ p.mu.Lock()
1049+ if p.consumer == consumer {
1050+ p.consumer = 0
1051+ }
1052+ // An unacknowledged read was never delivered by this scanner. Leave it
1053+ // pending and make it available to its replacement.
1054+ if p.delivering == consumer {
1055+ p.delivering = 0
1056+ p.deliveringResidual = false
1057+ }
1058+ p.mu.Unlock()
1059+ p.signalReady()
1060+ if p.afterRevoke != nil {
1061+ p.afterRevoke()
1062+ }
1063+}
8711064
872-var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873-
874-// paletteBoundaryResponses accepts valid DECRQM status values for private
875-// mode 2031. The private mode number correlates this reply with the query
876-// that starts a replacement palette generation.
877-var paletteBoundaryResponses = [][]byte{
878- []byte("\x1b[?2031;0$y"),
879- []byte("\x1b[?2031;1$y"),
880- []byte("\x1b[?2031;2$y"),
881- []byte("\x1b[?2031;3$y"),
882- []byte("\x1b[?2031;4$y"),
1065+func (p *terminalInputPump) signalReady() {
1066+ select {
1067+ case p.ready <- struct{}{}:
1068+ default:
1069+ }
8831070 }
8841071
885-// scan consumes the private-mode boundary response while forwarding every
886-// other byte in order. It retains only a possible response prefix, so split
887-// replies are consumed without dropping a user's partial escape sequence on
888-// cancellation.
889-func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
890- for _, b := range data {
891- d.pending = append(d.pending, b)
892- for len(d.pending) > 0 {
893- matched := false
894- possible := false
895- for _, response := range paletteBoundaryResponses {
896- if len(d.pending) > len(response) {
897- continue
1072+// enqueue retains at most one completed terminal read. The reader blocks
1073+// before publishing another result, keeping handoff buffering bounded.
1074+func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64) bool {
1075+ select {
1076+ case <-p.done:
1077+ return false
1078+ case <-p.space:
1079+ }
1080+ p.mu.Lock()
1081+ if p.closed || !p.active || p.activation != activation {
1082+ p.mu.Unlock()
1083+ p.space <- struct{}{}
1084+ return false
1085+ }
1086+ p.pending = &result
1087+ p.mu.Unlock()
1088+ p.signalReady()
1089+ return true
1090+}
1091+
1092+// take leases a ready result only to the current, non-cancelled consumer.
1093+// The raw read remains pending until ack, so revocation or cancellation before
1094+// scanner delivery leaves the exact bytes available to the next attempt.
1095+func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {
1096+ p.mu.Lock()
1097+ if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {
1098+ ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
1099+ p.mu.Unlock()
1100+ if ready {
1101+ p.signalReady()
1102+ }
1103+ return terminalReadResult{}, false
1104+ }
1105+ if len(p.residual) != 0 {
1106+ result := terminalReadResult{data: append([]byte(nil), p.residual...)}
1107+ p.delivering = consumer
1108+ p.deliveringResidual = true
1109+ p.mu.Unlock()
1110+ return result, true
1111+ }
1112+ if p.pending == nil {
1113+ p.mu.Unlock()
1114+ return terminalReadResult{}, false
1115+ }
1116+ result := *p.pending
1117+ p.delivering = consumer
1118+ p.deliveringResidual = false
1119+ p.mu.Unlock()
1120+ return result, true
1121+}
1122+
1123+// preserveResidual records the undecided marker prefix after callbacks for the
1124+// rest of its read have completed. It is intentionally bounded by the marker
1125+// scanner's fixed response prefixes and is replayed before later terminal
1126+// reads on a replacement attempt.
1127+func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
1128+ if len(data) == 0 {
1129+ return
1130+ }
1131+ p.mu.Lock()
1132+ defer p.mu.Unlock()
1133+ if p.consumer != consumer {
1134+ return
1135+ }
1136+ p.residual = append(p.residual[:0], data...)
1137+}
1138+
1139+// ack commits scanner delivery of a leased read and lets the lifecycle reader
1140+// accept the next result. Only the consumer that holds the lease can ack it.
1141+func (p *terminalInputPump) ack(consumer uint64) {
1142+ p.mu.Lock()
1143+ if p.consumer != consumer || p.delivering != consumer {
1144+ p.mu.Unlock()
1145+ return
1146+ }
1147+ wasResidual := p.deliveringResidual
1148+ if wasResidual {
1149+ p.residual = nil
1150+ } else {
1151+ p.pending = nil
1152+ }
1153+ p.delivering = 0
1154+ p.deliveringResidual = false
1155+ more := len(p.residual) != 0 || p.pending != nil
1156+ p.mu.Unlock()
1157+ if wasResidual {
1158+ if more {
1159+ p.signalReady()
1160+ }
1161+ return
1162+ }
1163+ p.space <- struct{}{}
1164+}
1165+
1166+func (p *terminalInputPump) finish() {
1167+ p.mu.Lock()
1168+ p.closed = true
1169+ p.mu.Unlock()
1170+ p.signalReady()
1171+ p.signalState()
1172+}
1173+
1174+func (p *terminalInputPump) signalState() {
1175+ select {
1176+ case p.state <- struct{}{}:
1177+ default:
1178+ }
1179+}
1180+
1181+func (p *terminalInputPump) waitForActive() (uint64, bool) {
1182+ for {
1183+ p.mu.Lock()
1184+ active, closed, activation := p.active, p.closed, p.activation
1185+ p.mu.Unlock()
1186+ if closed {
1187+ return 0, false
1188+ }
1189+ if active {
1190+ return activation, true
1191+ }
1192+ select {
1193+ case <-p.done:
1194+ return 0, false
1195+ case <-p.state:
1196+ }
1197+ }
1198+}
1199+
1200+func (p *terminalInputPump) start() {
1201+ p.startMu.Do(func() {
1202+ go func() {
1203+ defer close(p.exited)
1204+ buf := make([]byte, stdinBufSize)
1205+ for {
1206+ activation, ok := p.waitForActive()
1207+ if !ok {
1208+ return
8981209 }
899- match := true
900- for i := range d.pending {
901- if d.pending[i] != response[i] {
902- match = false
903- break
904- }
1210+ n, err := p.in.Read(buf)
1211+ result := terminalReadResult{err: err}
1212+ if n > 0 {
1213+ result.data = append([]byte(nil), buf[:n]...)
9051214 }
906- if !match {
1215+ if !p.enqueue(result, activation) {
1216+ if p.isClosed() {
1217+ return
1218+ }
9071219 continue
9081220 }
909- if len(d.pending) == len(response) {
910- matched = true
911- } else {
912- possible = true
1221+ if err != nil {
1222+ p.finish()
1223+ return
9131224 }
9141225 }
915- if matched {
916- d.pending = nil
917- onBoundary()
918- break
919- }
920- if possible {
921- break
922- }
923- onBytes(d.pending[:1])
924- d.pending = d.pending[1:]
1226+ }()
1227+ })
1228+}
1229+
1230+func (p *terminalInputPump) hasExited() bool {
1231+ select {
1232+ case <-p.exited:
1233+ return true
1234+ default:
1235+ return false
1236+ }
1237+}
1238+
1239+func (p *terminalInputPump) isClosed() bool {
1240+ p.mu.Lock()
1241+ defer p.mu.Unlock()
1242+ return p.closed
1243+}
1244+
1245+// suspend drops input completed after an attach ends and parks the lifecycle
1246+// reader before its next Read. A later Run resumes this same reader, avoiding
1247+// both input replay between runs and a competing read on caller-owned stdin.
1248+func (p *terminalInputPump) suspend() {
1249+ p.mu.Lock()
1250+ p.active = false
1251+ discarded := p.pending != nil
1252+ p.residual = nil
1253+ p.pending = nil
1254+ p.delivering = 0
1255+ p.deliveringResidual = false
1256+ p.mu.Unlock()
1257+ if discarded {
1258+ select {
1259+ case p.space <- struct{}{}:
1260+ default:
9251261 }
9261262 }
1263+ p.signalState()
1264+ p.signalReady()
9271265 }
9281266
929-func (d *paletteDrain) flush(onBytes func([]byte)) {
930- if len(d.pending) != 0 {
931- onBytes(d.pending)
932- d.pending = nil
1267+func (p *terminalInputPump) resume() {
1268+ p.mu.Lock()
1269+ if !p.closed && !p.active {
1270+ p.activation++
1271+ p.active = true
9331272 }
1273+ p.mu.Unlock()
1274+ p.signalState()
9341275 }
9351276
1277+func (p *terminalInputPump) stop() {
1278+ p.stopMu.Do(func() {
1279+ // Mark closure while holding the same mutex that publishes a completed
1280+ // Read. A Read which returns after stop therefore cannot publish even
1281+ // when both done and space are selectable in enqueue.
1282+ p.mu.Lock()
1283+ p.closed = true
1284+ p.mu.Unlock()
1285+ close(p.done)
1286+ p.signalReady()
1287+ p.signalState()
1288+ })
1289+}
1290+
1291+// stdinPump parses terminal color reports into generation-tagged events while
1292+// preserving all ordinary terminal input byte-for-byte. It never reads the
1293+// terminal directly, publishes a Theme, or writes terminal output;
1294+// attachAttempt owns those operations.
9361295 type stdinPump struct {
937- ctx context.Context
938- cancel context.CancelFunc
939- in io.Reader
940- out chan<- ports.Frame
941- clock ports.Clock
942- themeState *terminalThemeState
943- clipboard ports.ClipboardReader
944- logger *slog.Logger
945- requestColors func() (colorQueryRequest, bool)
946- paletteReady chan<- paletteQueryReady
1296+ ctx context.Context
1297+ cancel context.CancelFunc
1298+ in io.Reader // compatibility for isolated scanner tests
1299+ input *terminalInputPump
1300+ consumer uint64
1301+ out chan<- ports.Frame
1302+ clock ports.Clock
1303+ clipboard ports.ClipboardReader
1304+ logger *slog.Logger
1305+ paletteEvents chan<- paletteGenerationEvent
1306+ activeGeneration *atomic.Uint64
1307+ afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook
1308+ afterInputTake func() // test synchronization hook
1309+ afterInputDelivered func() // test synchronization hook
9471310 }
9481311
9491312 func (p *stdinPump) run() {
950- ctx := p.ctx
951- cancel := p.cancel
952- in := p.in
953- out := p.out
954- clk := p.clock
955- themeState := p.themeState
956- requestColors := p.requestColors
957- clipboard := p.clipboard
958- log := p.logger
959- defer log.Debug("stdin pump exited")
960- buf := make([]byte, stdinBufSize)
1313+ defer p.logger.Debug("stdin pump exited")
9611314 var scanner theme.Scanner
1315+ var markers paletteMarkerScanner
9621316 var inputSeq uint64
9631317 var sendOK atomic.Bool
1318+ var undeliveredMu sync.Mutex
1319+ var undelivered []byte
9641320 sendOK.Store(true)
965- send := func(frame ports.Frame) {
1321+ send := func(frame ports.Frame) bool {
1322+ if !sendOK.Load() {
1323+ return false
1324+ }
9661325 select {
967- case out <- frame:
968- case <-ctx.Done():
1326+ case p.out <- frame:
1327+ if p.afterInputDelivered != nil {
1328+ p.afterInputDelivered()
1329+ }
1330+ return true
1331+ case <-p.ctx.Done():
9691332 sendOK.Store(false)
1333+ return false
9701334 }
9711335 }
972- // The coalescer reframes a bracketed paste split across reads into one
973- // MsgInput frame, so a marker boundary can never leave a lone ESC on the
974- // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
975- coalescer := newPasteCoalescer(clk, func(data []byte) {
1336+ sendEvent := func(event paletteGenerationEvent) bool {
1337+ if !sendOK.Load() {
1338+ return false
1339+ }
1340+ select {
1341+ case p.paletteEvents <- event:
1342+ if p.afterPaletteEvent != nil {
1343+ p.afterPaletteEvent(event)
1344+ }
1345+ return true
1346+ case <-p.ctx.Done():
1347+ sendOK.Store(false)
1348+ return false
1349+ }
1350+ }
1351+ coalescer := newPasteCoalescer(p.clock, func(data []byte) {
9761352 if len(data) == 0 {
9771353 return
9781354 }
979- copyData := append([]byte(nil), data...)
9801355 inputSeq++
981- send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
1356+ if !send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})}) {
1357+ undeliveredMu.Lock()
1358+ undelivered = append(undelivered, data...)
1359+ undeliveredMu.Unlock()
1360+ }
9821361 })
9831362 defer coalescer.Close()
984-
985- // On a remote attach with a ClipboardReader configured, splice in the
986- // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
987- // becomes a clipboard image push instead of reaching the coalescer (and
988- // from there the remote pane) as an ordinary keystroke.
9891363 sink := coalescer.Scan
990- if clipboard != nil {
1364+ if p.clipboard != nil {
9911365 ci := &clipboardIntercept{
9921366 coalescer: coalescer,
993- reader: clipboard,
994- log: log,
1367+ reader: p.clipboard,
1368+ log: p.logger,
9951369 sendImage: func(mime string, data []byte) {
9961370 inputSeq++
9971371 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
@@ -1000,99 +1374,156 @@ func (p *stdinPump) run() {
10001374 }
10011375 sink = ci.Scan
10021376 }
1377+ input := p.input
1378+ ownedInput := input == nil
1379+ if ownedInput {
1380+ input = newTerminalInputPump(p.in)
1381+ input.start()
1382+ defer input.stop()
1383+ }
1384+ consumer := p.consumer
1385+ if consumer == 0 {
1386+ consumer = input.claim()
1387+ defer input.revoke(consumer)
1388+ }
1389+ // A marker prefix is ordinary terminal input until it becomes a complete
1390+ // DECRQM reply. If cancellation wins while it is withheld, preserve just
1391+ // that undecided suffix; the rest of its source read has already been
1392+ // delivered and must not be replayed by the next attempt.
1393+ defer func() {
1394+ if p.ctx.Err() != nil {
1395+ // Closing flushes any bracketed-paste prefix or buffer through the
1396+ // coalescer's emit callback. Failed ordinary-input deliveries are
1397+ // retained ahead of a trailing undecided marker prefix, matching their
1398+ // original order within the raw read.
1399+ held := coalescer.CloseAndTakeHeld()
1400+ undeliveredMu.Lock()
1401+ residual := append([]byte(nil), undelivered...)
1402+ undeliveredMu.Unlock()
1403+ residual = append(residual, held...)
1404+ residual = append(residual, markers.takePending()...)
1405+ input.preserveResidual(consumer, residual)
1406+ }
1407+ }()
1408+
1409+ var markerTimer ports.Timer
1410+ var markerTimerC <-chan time.Time
1411+ flushMarkerPrefix := func() {
1412+ markers.flush(sink)
1413+ markerTimer = nil
1414+ markerTimerC = nil
1415+ }
1416+ // Disarm before processing a subsequent read. A deadline already ready at
1417+ // that boundary wins, so bytes cannot be retroactively consumed as a
1418+ // marker after they were due to be forwarded as ordinary input.
1419+ disarmMarkerDeadline := func() {
1420+ if markerTimer == nil {
1421+ return
1422+ }
1423+ select {
1424+ case <-markerTimerC:
1425+ flushMarkerPrefix()
1426+ return
1427+ default:
1428+ }
1429+ if !markerTimer.Stop() {
1430+ // Stop reports false only after expiry or a prior stop. This timer is
1431+ // owned only here, so expiry wins even if delivery to C races this
1432+ // select; do not let a late value consume a newly arrived suffix.
1433+ flushMarkerPrefix()
1434+ return
1435+ }
1436+ markerTimer = nil
1437+ markerTimerC = nil
1438+ }
1439+ armMarkerDeadline := func() {
1440+ disarmMarkerDeadline()
1441+ markerTimer = p.clock.NewTimer(paletteMarkerAmbiguityDeadline)
1442+ markerTimerC = markerTimer.C()
1443+ }
1444+ defer disarmMarkerDeadline()
10031445
1004- phase := paletteAccepting
1005- var drain paletteDrain
10061446 for {
1007- n, rerr := in.Read(buf)
1008- if n > 0 {
1009- themeChanged := false
1010- queryNeeded := false
1011- onBytes := func(data []byte) {
1012- if phase != paletteDraining {
1013- sink(data)
1447+ select {
1448+ case <-p.ctx.Done():
1449+ return
1450+ case <-markerTimerC:
1451+ flushMarkerPrefix()
1452+ case <-input.ready:
1453+ result, ok := input.take(p.ctx, consumer)
1454+ if !ok {
1455+ if p.ctx.Err() != nil {
10141456 return
10151457 }
1016- drain.scan(data, sink, func() {
1017- phase = paletteAwaitingQuery
1018- ready := paletteQueryReady{queryIssued: make(chan struct{})}
1019- select {
1020- case p.paletteReady <- ready:
1021- case <-ctx.Done():
1022- return
1023- }
1024- select {
1025- case <-ready.queryIssued:
1026- phase = paletteAccepting
1027- case <-ctx.Done():
1028- }
1029- })
1458+ continue
10301459 }
1031- scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
1032- if phase != paletteAccepting {
1033- return
1034- }
1035- themeState.update(func(current *ports.Theme) {
1036- switch kind {
1037- case 10:
1038- current.HasForeground = true
1039- current.Foreground = rgb
1040- case 11:
1041- current.HasBackground = true
1042- current.Background = rgb
1460+ if p.afterInputTake != nil {
1461+ p.afterInputTake()
1462+ }
1463+ // The lease is not acknowledged until scanner delivery completes.
1464+ // This second check closes the cancellation window after take.
1465+ if p.ctx.Err() != nil {
1466+ return
1467+ }
1468+ disarmMarkerDeadline()
1469+ // All scanner callbacks from this Read must retain this one generation.
1470+ // A scheme notification can start its replacement before a later byte in
1471+ // the same read is scanned; reloading there would misclassify an old
1472+ // completion marker as the replacement's drain response.
1473+ readGeneration := paletteGenerationID(p.activeGeneration.Load())
1474+ if len(result.data) > 0 {
1475+ scanner.Scan(result.data, func(kind int, rgb renderer.RGB) {
1476+ kindEvent := paletteEventForeground
1477+ if kind == 11 {
1478+ kindEvent = paletteEventBackground
10431479 }
1044- })
1045- themeChanged = true
1046- }, func(slot int, rgb renderer.RGB) {
1047- if phase != paletteAccepting {
1048- return
1049- }
1050- themeState.update(func(current *ports.Theme) {
1051- current.PaletteKnown |= uint16(1) << slot
1052- current.Palette[slot] = rgb
1053- })
1054- themeChanged = true
1055- }, func(light bool) {
1056- themeState.update(func(current *ports.Theme) {
1057- current.SchemeKnown = true
1058- current.Light = light
1059- })
1060- // The scheme itself begins a new palette generation. Suppress every
1061- // color reply after it, including those remaining in this read, and
1062- // defer invalidation until Scan completes so no partial snapshot is
1063- // observable mid-chunk.
1064- themeChanged = false
1065- if phase == paletteAccepting {
1066- phase = paletteBoundaryRequested
1067- queryNeeded = true
1068- }
1069- }, onBytes)
1070- if queryNeeded {
1071- themeState.clearPalette()
1072- if requestColors != nil {
1073- if _, ok := requestColors(); !ok {
1480+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
1481+ }, func(slot int, rgb renderer.RGB) {
1482+ // Scanner implementations are independently testable; retain the
1483+ // protocol boundary here too so a malformed implementation cannot
1484+ // wrap a negative or oversized int into an ANSI palette slot.
1485+ slot8, ok := paletteSlot(slot)
1486+ if !ok {
10741487 return
10751488 }
1076- phase = paletteDraining
1489+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb})
1490+ }, func(light bool) {
1491+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
1492+ }, func(data []byte) {
1493+ markers.scan(data, sink, func() {
1494+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
1495+ })
1496+ })
1497+ if !sendOK.Load() {
1498+ // Commit the source read so callbacks accepted before cancellation
1499+ // are never replayed. The deferred handoff retains only ordinary
1500+ // bytes whose callback could not be delivered, plus any undecided
1501+ // trailing marker prefix.
1502+ input.ack(consumer)
1503+ return
10771504 }
1078- } else if themeChanged {
1079- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
10801505 }
1081- if !sendOK.Load() {
1082- return
1506+ if markers.hasPendingPrefix() {
1507+ armMarkerDeadline()
10831508 }
1084- }
1085- if rerr != nil {
1086- if phase == paletteDraining {
1087- drain.flush(sink)
1509+ if result.err != nil {
1510+ disarmMarkerDeadline()
1511+ markers.flush(sink)
1512+ select {
1513+ case p.out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
1514+ case <-p.ctx.Done():
1515+ }
1516+ p.cancel()
1517+ return
10881518 }
1089- // Best-effort detach notification, then unwind.
1090- select {
1091- case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
1092- case <-ctx.Done():
1519+ // Commit every callback from this raw read before observing a
1520+ // concurrent cancellation. Any remaining marker prefix is retained
1521+ // by the deferred handoff above, so reconnects never duplicate the
1522+ // delivered bytes or lose a standalone Escape.
1523+ input.ack(consumer)
1524+ if p.ctx.Err() != nil {
1525+ return
10931526 }
1094- cancel()
1095- return
10961527 }
10971528 }
10981529 }
M internal/usecase/client/client_test.go +24 -365
@@ -292,6 +292,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
292292 case <-time.After(time.Second):
293293 t.Fatal("pre-Welcome operation leaked after Run returned")
294294 }
295+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
295296 })
296297 }
297298 }
@@ -308,6 +309,8 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
308309
309310 gotHello := make(chan ports.Hello, 1)
310311 tr := portsmocks.NewMockTransport(t)
312+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
313+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
311314 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
312315 hello, err := ports.UnmarshalHello(f.Payload)
313316 require.NoError(t, err)
@@ -345,6 +348,8 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
345348
346349 gotHello := make(chan ports.Hello, 1)
347350 tr := portsmocks.NewMockTransport(t)
351+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
352+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
348353 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
349354 hello, err := ports.UnmarshalHello(f.Payload)
350355 if err != nil {
@@ -372,6 +377,8 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
372377 defer in.unblock()
373378
374379 tr := portsmocks.NewMockTransport(t)
380+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
381+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
375382 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
376383 hello, err := ports.UnmarshalHello(f.Payload)
377384 require.NoError(t, err)
@@ -396,6 +403,8 @@ func TestAttachHappyPath(t *testing.T) {
396403 defer in.unblock()
397404
398405 tr := portsmocks.NewMockTransport(t)
406+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
407+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
399408 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
400409 unblock := scriptRecv(tr,
401410 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},
@@ -407,7 +416,7 @@ func TestAttachHappyPath(t *testing.T) {
407416
408417 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
409418 require.NoError(t, err)
410- require.Equal(t, "hello world", out.String())
419+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$phello world", out.String())
411420 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
412421 }
413422
@@ -429,6 +438,7 @@ func TestAttachVersionMismatch(t *testing.T) {
429438 var pe *client.ProtocolError
430439 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
431440 require.Equal(t, ports.ErrVersionMismatch, pe.Code)
441+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
432442 }
433443
434444 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
@@ -439,6 +449,8 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
439449 defer in.unblock()
440450
441451 tr := portsmocks.NewMockTransport(t)
452+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
453+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
442454 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
443455 boom := errors.New("connection reset")
444456 unblock := scriptRecv(tr,
@@ -461,6 +473,8 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
461473 defer in.unblock()
462474
463475 tr := portsmocks.NewMockTransport(t)
476+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
477+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
464478 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
465479 unblock := scriptRecv(tr,
466480 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
@@ -475,221 +489,6 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
475489 require.Equal(t, int32(1), restoreCount.Load())
476490 }
477491
478-func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
479- t.Setenv("COLORTERM", "truecolor")
480-
481- var out bytes.Buffer
482- var restoreCount atomic.Int32
483- resizeCh := make(chan domain.Size)
484- input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b"))
485- defer input.unblock()
486-
487- tm := portsmocks.NewMockTerminal(t)
488- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
489- tm.EXPECT().EnterRaw().Return(func() error {
490- restoreCount.Add(1)
491- return nil
492- }, nil).Once()
493- tm.EXPECT().In().Return(input).Maybe()
494- tm.EXPECT().Out().Return(&out).Maybe()
495- tm.EXPECT().Flush().Return(nil).Maybe()
496- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
497-
498- gotTheme := make(chan ports.Theme, 1)
499- gotInput := make(chan []byte, 2)
500- allowDetach := make(chan struct{})
501-
502- tr := portsmocks.NewMockTransport(t)
503- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
504- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
505- in, err := ports.UnmarshalInput(f.Payload)
506- require.NoError(t, err)
507- require.NotContains(t, string(in.Data), "\x1b]11;")
508- gotInput <- append([]byte(nil), in.Data...)
509- if bytes.Contains(in.Data, []byte("b")) {
510- close(allowDetach)
511- }
512- return nil
513- }).Maybe()
514- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
515- th, err := ports.UnmarshalTheme(f.Payload)
516- require.NoError(t, err)
517- gotTheme <- th
518- return nil
519- }).Once()
520-
521- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
522- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
523- recvCh := make(chan recvItem, 1)
524- recvCh <- recvItem{f: welcome}
525- closed := make(chan struct{})
526- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
527- select {
528- case it := <-recvCh:
529- return it.f, it.err
530- case <-allowDetach:
531- select {
532- case <-closed:
533- return ports.Frame{}, io.EOF
534- default:
535- close(closed)
536- return detached, nil
537- }
538- case <-closed:
539- return ports.Frame{}, io.EOF
540- }
541- }).Maybe()
542- tr.EXPECT().Close().Return(nil).Once()
543-
544- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
545- require.NoError(t, err)
546- require.Equal(t, int32(1), restoreCount.Load())
547-
548- select {
549- case th := <-gotTheme:
550- require.True(t, th.HasBackground)
551- require.Equal(t, uint8(1), th.Background.R)
552- require.Equal(t, uint8(2), th.Background.G)
553- require.Equal(t, uint8(3), th.Background.B)
554- require.True(t, th.TrueColor)
555- case <-time.After(2 * time.Second):
556- t.Fatal("theme frame was not sent")
557- }
558-
559- var inputBytes []byte
560- for {
561- select {
562- case b := <-gotInput:
563- inputBytes = append(inputBytes, b...)
564- default:
565- require.Equal(t, []byte("ab"), inputBytes)
566- return
567- }
568- }
569-}
570-
571-func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
572- t.Setenv("TERM", "xterm-256color")
573- t.Setenv("COLORTERM", "")
574-
575- var out bytes.Buffer
576- var restoreCount atomic.Int32
577- resizeCh := make(chan domain.Size)
578- input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07"))
579- defer input.unblock()
580-
581- tm := portsmocks.NewMockTerminal(t)
582- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
583- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
584- tm.EXPECT().In().Return(input).Maybe()
585- tm.EXPECT().Out().Return(&out).Maybe()
586- tm.EXPECT().Flush().Return(nil).Maybe()
587- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
588-
589- gotTheme := make(chan ports.Theme, 1)
590- allowDetach := make(chan struct{})
591- tr := portsmocks.NewMockTransport(t)
592- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
593- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
594- th, err := ports.UnmarshalTheme(f.Payload)
595- require.NoError(t, err)
596- gotTheme <- th
597- close(allowDetach)
598- return nil
599- }).Once()
600- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
601- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
602- recvCh := make(chan recvItem, 1)
603- recvCh <- recvItem{f: welcome}
604- closed := make(chan struct{})
605- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
606- select {
607- case it := <-recvCh:
608- return it.f, it.err
609- case <-allowDetach:
610- select {
611- case <-closed:
612- return ports.Frame{}, io.EOF
613- default:
614- close(closed)
615- return detached, nil
616- }
617- case <-closed:
618- return ports.Frame{}, io.EOF
619- }
620- }).Maybe()
621- tr.EXPECT().Close().Return(nil).Once()
622-
623- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
624- require.NoError(t, err)
625- select {
626- case th := <-gotTheme:
627- require.True(t, th.HasForeground)
628- require.False(t, th.TrueColor)
629- case <-time.After(2 * time.Second):
630- t.Fatal("theme frame was not sent")
631- }
632-}
633-
634-func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
635- var out bytes.Buffer
636- var restoreCount atomic.Int32
637- resizeCh := make(chan domain.Size)
638- input := newOneShotBlockingReader([]byte("\x1b"))
639- defer input.unblock()
640-
641- tm := portsmocks.NewMockTerminal(t)
642- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
643- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
644- tm.EXPECT().In().Return(input).Maybe()
645- tm.EXPECT().Out().Return(&out).Maybe()
646- tm.EXPECT().Flush().Return(nil).Maybe()
647- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
648-
649- gotInput := make(chan []byte, 1)
650- allowDetach := make(chan struct{})
651- tr := portsmocks.NewMockTransport(t)
652- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
653- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
654- in, err := ports.UnmarshalInput(f.Payload)
655- require.NoError(t, err)
656- gotInput <- in.Data
657- close(allowDetach)
658- return nil
659- }).Once()
660- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
661- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
662- recvCh := make(chan recvItem, 1)
663- recvCh <- recvItem{f: welcome}
664- closed := make(chan struct{})
665- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
666- select {
667- case it := <-recvCh:
668- return it.f, it.err
669- case <-allowDetach:
670- select {
671- case <-closed:
672- return ports.Frame{}, io.EOF
673- default:
674- close(closed)
675- return detached, nil
676- }
677- case <-closed:
678- return ports.Frame{}, io.EOF
679- }
680- }).Maybe()
681- tr.EXPECT().Close().Return(nil).Once()
682-
683- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
684- require.NoError(t, err)
685- select {
686- case got := <-gotInput:
687- require.Equal(t, []byte("\x1b"), got)
688- case <-time.After(2 * time.Second):
689- t.Fatal("standalone escape input was not sent")
690- }
691-}
692-
693492 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
694493 var out bytes.Buffer
695494 var restoreCount atomic.Int32
@@ -708,6 +507,8 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
708507 gotInput := make(chan []byte, 2)
709508 allowDetach := make(chan struct{})
710509 tr := portsmocks.NewMockTransport(t)
510+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
511+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
711512 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
712513 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
713514 in, err := ports.UnmarshalInput(f.Payload)
@@ -768,6 +569,8 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
768569 gotInput := make(chan []byte, 1)
769570 allowDetach := make(chan struct{})
770571 tr := portsmocks.NewMockTransport(t)
572+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
573+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
771574 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
772575 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
773576 in, err := ports.UnmarshalInput(f.Payload)
@@ -821,6 +624,8 @@ func TestAttachForwardsResize(t *testing.T) {
821624 var firstRecvOnce sync.Once
822625
823626 tr := portsmocks.NewMockTransport(t)
627+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
628+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
824629 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
825630 // The resize frame is forwarded via the sender goroutine.
826631 gotResize := make(chan ports.Resize, 1)
@@ -880,6 +685,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
880685 defer in.unblock()
881686
882687 tr := portsmocks.NewMockTransport(t)
688+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
689+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
883690 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
884691 boom := errors.New("connection reset")
885692 unblock := scriptRecv(tr,
@@ -915,6 +722,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
915722 require.Error(t, err)
916723 var pe *client.ProtocolError
917724 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
725+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
918726 }
919727
920728 func TestRunPhaseASingleAttempt(t *testing.T) {
@@ -987,7 +795,6 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
987795 }
988796 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
989797 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
990-func (t *runTerminal) QueryColors() error { return nil }
991798 func (t *runTerminal) In() io.Reader { return t.in }
992799 func (t *runTerminal) Out() io.Writer { return &t.out }
993800 func (t *runTerminal) Flush() error { return nil }
@@ -1047,151 +854,3 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
1047854 require.Equal(t, int32(1), d.calls.Load())
1048855 require.Equal(t, int32(1), term.restoreCount.Load())
1049856 }
1050-
1051-func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
1052- input := newChunkedBlockingReader(
1053- []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"),
1054- []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"),
1055- []byte("2$yc\x1b]4;3;#778899\ad"),
1056- )
1057- defer input.unblock()
1058-
1059- var out bytes.Buffer
1060- var restoreCount atomic.Int32
1061- resizeCh := make(chan domain.Size)
1062- tm := portsmocks.NewMockTerminal(t)
1063- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1064- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1065- tm.EXPECT().In().Return(input).Maybe()
1066- tm.EXPECT().Out().Return(&out).Maybe()
1067- tm.EXPECT().Flush().Return(nil).Maybe()
1068- tm.EXPECT().QueryColors().Return(nil).Once()
1069- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1070-
1071- var inputBytes []byte
1072- var inputMu sync.Mutex
1073- themes := make(chan ports.Theme, 2)
1074- freshTheme := make(chan struct{})
1075- var freshOnce sync.Once
1076- tr := portsmocks.NewMockTransport(t)
1077- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1078- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
1079- in, err := ports.UnmarshalInput(f.Payload)
1080- if err != nil {
1081- return err
1082- }
1083- inputMu.Lock()
1084- inputBytes = append(inputBytes, in.Data...)
1085- inputMu.Unlock()
1086- return nil
1087- }).Maybe()
1088- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1089- theme, err := ports.UnmarshalTheme(f.Payload)
1090- if err != nil {
1091- return err
1092- }
1093- themes <- theme
1094- if theme.PaletteKnown == 1<<3 {
1095- freshOnce.Do(func() { close(freshTheme) })
1096- }
1097- return nil
1098- }).Times(2)
1099- var welcomed atomic.Bool
1100- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1101- if welcomed.CompareAndSwap(false, true) {
1102- return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil
1103- }
1104- <-freshTheme
1105- return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil
1106- }).Maybe()
1107- tr.EXPECT().Close().Return(nil).Once()
1108-
1109- require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1110- require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write")
1111- require.Equal(t, int32(1), restoreCount.Load())
1112-
1113- cleared := <-themes
1114- fresh := <-themes
1115- require.True(t, cleared.SchemeKnown)
1116- require.True(t, cleared.Light)
1117- require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary")
1118- require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted")
1119- require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation")
1120- inputMu.Lock()
1121- require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
1122- inputMu.Unlock()
1123-}
1124-
1125-func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1126- var out bytes.Buffer
1127- var restoreCount atomic.Int32
1128- resizeCh := make(chan domain.Size)
1129- input := newChunkedBlockingReader(
1130- []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1131- []byte("\x1b[?997;2n"),
1132- []byte("\x1b[?2031;1$y"),
1133- )
1134- defer input.unblock()
1135-
1136- tm := portsmocks.NewMockTerminal(t)
1137- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1138- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1139- tm.EXPECT().In().Return(input).Maybe()
1140- tm.EXPECT().Out().Return(&out).Maybe()
1141- tm.EXPECT().Flush().Return(nil).Maybe()
1142- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1143- tm.EXPECT().QueryColors().Return(nil).Once()
1144-
1145- var themes []ports.Theme
1146- var themesMu sync.Mutex
1147- clearedPalette := make(chan struct{})
1148- var clearOnce sync.Once
1149- tr := portsmocks.NewMockTransport(t)
1150- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1151- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1152- theme, err := ports.UnmarshalTheme(f.Payload)
1153- if err != nil {
1154- t.Errorf("decode sent theme: %v", err)
1155- return err
1156- }
1157- themesMu.Lock()
1158- themes = append(themes, theme)
1159- themesMu.Unlock()
1160- if theme.SchemeKnown && theme.PaletteKnown == 0 {
1161- clearOnce.Do(func() { close(clearedPalette) })
1162- }
1163- return nil
1164- }).Times(2)
1165-
1166- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1167- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1168- closed := make(chan struct{})
1169- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1170- select {
1171- case <-closed:
1172- return ports.Frame{}, io.EOF
1173- default:
1174- }
1175- select {
1176- case <-clearedPalette:
1177- close(closed)
1178- return detached, nil
1179- default:
1180- return welcome, nil
1181- }
1182- }).Maybe()
1183- tr.EXPECT().Close().Return(nil).Once()
1184-
1185- require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1186- require.Equal(t, int32(1), restoreCount.Load())
1187-
1188- themesMu.Lock()
1189- defer themesMu.Unlock()
1190- require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent")
1191- require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
1192- require.True(t, themes[0].HasBackground)
1193- require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries")
1194- require.Equal(t, themes[0].Background, themes[1].Background)
1195- require.True(t, themes[1].SchemeKnown)
1196- require.True(t, themes[1].Light)
1197-}
M internal/usecase/client/clipboard_intercept_test.go +1 -0
@@ -42,6 +42,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
4242 allowDetach := make(chan struct{})
4343
4444 tr := portsmocks.NewMockTransport(t)
45+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
4546 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
4647 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
4748 in, err := ports.UnmarshalInput(f.Payload)
A internal/usecase/client/palette_attach_generation_test.go +542 -0
@@ -0,0 +1,542 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "strings"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/pkg/renderer"
19+)
20+
21+// attachPaletteClock deliberately exposes each independently-created timer.
22+// Tests fire only the deadline they intend to exercise; no wall clock is used.
23+type attachPaletteClock struct{ timers chan *attachPaletteTimer }
24+
25+type attachPaletteTimer struct {
26+ ch chan time.Time
27+ stopped chan struct{}
28+ stopOnce sync.Once
29+}
30+
31+func newAttachPaletteClock() *attachPaletteClock {
32+ return &attachPaletteClock{timers: make(chan *attachPaletteTimer, 16)}
33+}
34+
35+func (*attachPaletteClock) Now() time.Time { return time.Time{} }
36+func (c *attachPaletteClock) NewTimer(time.Duration) ports.Timer {
37+ timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{})}
38+ c.timers <- timer
39+ return timer
40+}
41+func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }
42+func (*attachPaletteTimer) Reset(time.Duration) bool { return false }
43+func (t *attachPaletteTimer) Stop() bool {
44+ t.stopOnce.Do(func() { close(t.stopped) })
45+ return true
46+}
47+func (t *attachPaletteTimer) fire() { t.ch <- time.Time{} }
48+
49+type attachPaletteReader struct{ chunks chan []byte }
50+
51+func newAttachPaletteReader() *attachPaletteReader {
52+ return &attachPaletteReader{chunks: make(chan []byte, 16)}
53+}
54+func (r *attachPaletteReader) Read(p []byte) (int, error) {
55+ data := <-r.chunks
56+ return copy(p, data), nil
57+}
58+func (r *attachPaletteReader) send(data string) { r.chunks <- []byte(data) }
59+
60+type attachPaletteWriter struct {
61+ mu sync.Mutex
62+ writes []string
63+ wrote chan string
64+ err error
65+}
66+
67+func newAttachPaletteWriter() *attachPaletteWriter {
68+ return &attachPaletteWriter{wrote: make(chan string, 16)}
69+}
70+func (w *attachPaletteWriter) Write(data []byte) (int, error) {
71+ copyData := string(data)
72+ w.mu.Lock()
73+ w.writes = append(w.writes, copyData)
74+ err := w.err
75+ w.mu.Unlock()
76+ if err != nil {
77+ return 0, err
78+ }
79+ w.wrote <- copyData
80+ return len(data), nil
81+}
82+
83+type attachPaletteTerminalHarness struct {
84+ in io.Reader
85+ out *attachPaletteWriter
86+ resize chan domain.Size
87+}
88+
89+func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
90+ return func() error { return nil }, nil
91+}
92+func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {
93+ return domain.Size{Cols: 80, Rows: 24}, nil
94+}
95+func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }
96+func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
97+func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
98+func (*attachPaletteTerminalHarness) Flush() error { return nil }
99+
100+type attachPaletteTransport struct {
101+ mu sync.Mutex
102+ frames []ports.Frame
103+ themes []ports.Theme
104+ themeCh chan ports.Theme
105+ inputCh chan ports.Input
106+ detached chan ports.Frame
107+ themeSendErr error
108+ welcomed bool
109+}
110+
111+func newAttachPaletteTransport() *attachPaletteTransport {
112+ return &attachPaletteTransport{
113+ themeCh: make(chan ports.Theme, 16),
114+ inputCh: make(chan ports.Input, 16),
115+ detached: make(chan ports.Frame, 1),
116+ }
117+}
118+func (t *attachPaletteTransport) Send(frame ports.Frame) error {
119+ t.mu.Lock()
120+ t.frames = append(t.frames, frame)
121+ themeSendErr := t.themeSendErr
122+ t.mu.Unlock()
123+ switch frame.Type {
124+ case ports.MsgTheme:
125+ if themeSendErr != nil {
126+ return themeSendErr
127+ }
128+ got, err := ports.UnmarshalTheme(frame.Payload)
129+ if err != nil {
130+ return err
131+ }
132+ t.mu.Lock()
133+ t.themes = append(t.themes, got)
134+ t.mu.Unlock()
135+ t.themeCh <- got
136+ case ports.MsgInput:
137+ got, err := ports.UnmarshalInput(frame.Payload)
138+ if err != nil {
139+ return err
140+ }
141+ t.inputCh <- got
142+ }
143+ return nil
144+}
145+func (t *attachPaletteTransport) Recv() (ports.Frame, error) {
146+ t.mu.Lock()
147+ if !t.welcomed {
148+ t.welcomed = true
149+ t.mu.Unlock()
150+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
151+ }
152+ t.mu.Unlock()
153+ return <-t.detached, nil
154+}
155+func (*attachPaletteTransport) Close() error { return nil }
156+func (t *attachPaletteTransport) snapshots() []ports.Theme {
157+ t.mu.Lock()
158+ defer t.mu.Unlock()
159+ return append([]ports.Theme(nil), t.themes...)
160+}
161+
162+type attachPaletteHarness struct {
163+ clock *attachPaletteClock
164+ reader *attachPaletteReader
165+ writer *attachPaletteWriter
166+ transport *attachPaletteTransport
167+ done chan attachResult
168+}
169+
170+func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
171+ return startAttachPaletteHarnessWithTransport(state, newAttachPaletteTransport())
172+}
173+
174+func startAttachPaletteHarnessWithTransport(state *terminalThemeState, transport *attachPaletteTransport) *attachPaletteHarness {
175+ return startAttachPaletteHarnessWithInput(state, transport, nil)
176+}
177+
178+func startAttachPaletteHarnessWithInput(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump) *attachPaletteHarness {
179+ return startAttachPaletteHarnessWithInputAndWriteError(state, transport, input, nil)
180+}
181+
182+func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump, writeErr error) *attachPaletteHarness {
183+ clock := newAttachPaletteClock()
184+ reader := newAttachPaletteReader()
185+ writer := newAttachPaletteWriter()
186+ writer.err = writeErr
187+ term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}
188+ ms := &milestones{}
189+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
190+ attempt := &attachAttempt{
191+ runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
192+ milestones: ms, themeState: state,
193+ enterRaw: func() error { ms.rawEntered = true; return nil },
194+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
195+ terminalInput: func() *terminalInputPump {
196+ return input
197+ },
198+ }
199+ h := &attachPaletteHarness{clock: clock, reader: reader, writer: writer, transport: transport, done: make(chan attachResult, 1)}
200+ go func() { h.done <- attempt.run(context.Background()) }()
201+ // The handshake's two bounded-pre-welcome timers are unrelated to palette
202+ // acquisition. Remove them in this harness so every exposed timer is a
203+ // generation deadline (or a timer deliberately created by input handling).
204+ <-clock.timers
205+ <-clock.timers
206+ return h
207+}
208+
209+func (h *attachPaletteHarness) nextWrite(t *testing.T) string {
210+ t.Helper()
211+ return <-h.writer.wrote
212+}
213+func (h *attachPaletteHarness) nextTheme(t *testing.T) ports.Theme {
214+ t.Helper()
215+ return <-h.transport.themeCh
216+}
217+func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
218+ t.Helper()
219+ return <-h.clock.timers
220+}
221+func (h *attachPaletteHarness) detach(t *testing.T) {
222+ t.Helper()
223+ h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
224+ require.NoError(t, (<-h.done).err)
225+}
226+
227+func paletteReply(foreground, background string, slot uint8, color string) string {
228+ return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
229+}
230+
231+func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
232+ t.Setenv("COLORTERM", "truecolor")
233+ state := &terminalThemeState{}
234+ first := startAttachPaletteHarness(state)
235+ require.Equal(t, paletteColorBatch, first.nextWrite(t))
236+ first.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
237+ _ = first.nextTheme(t) // initial cleared snapshot
238+ final := first.nextTheme(t)
239+ require.True(t, final.HasForeground)
240+ first.detach(t)
241+
242+ reconnected := startAttachPaletteHarness(state)
243+ require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
244+ cleared := reconnected.nextTheme(t)
245+ require.True(t, cleared.HasForeground)
246+ require.Equal(t, final.Foreground, cleared.Foreground)
247+ require.True(t, cleared.HasBackground)
248+ require.Equal(t, final.Background, cleared.Background)
249+ require.Zero(t, cleared.PaletteKnown)
250+ require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
251+
252+ reconnected.reader.send(paletteReply("#111213", "#141516", 2, "#202122"))
253+ _ = reconnected.nextTheme(t)
254+ reconnected.detach(t)
255+ require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
256+}
257+
258+func TestAttachReconnectPreservesStandaloneEscapeBeforeClaimRevocation(t *testing.T) {
259+ t.Setenv("COLORTERM", "truecolor")
260+ reader := newAttachPaletteReader()
261+ input := newTerminalInputPump(reader)
262+ input.start()
263+ t.Cleanup(input.stop)
264+
265+ first := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
266+ require.Equal(t, paletteColorBatch, first.nextWrite(t))
267+ _ = first.nextTheme(t)
268+ _ = first.nextTimer(t) // initial palette completion deadline
269+
270+ // A standalone Escape is withheld as a possible DECRQM marker prefix. Its
271+ // raw read is committed, leaving only the scanner-local residual to hand
272+ // over when this attach is replaced.
273+ reader.send("\x1b")
274+ _ = first.nextTimer(t) // standalone Escape ambiguity deadline
275+
276+ revoked := make(chan []byte, 1)
277+ allowRevocationToReturn := make(chan struct{})
278+ var releaseRevocation sync.Once
279+ release := func() { releaseRevocation.Do(func() { close(allowRevocationToReturn) }) }
280+ defer release()
281+ input.afterRevoke = func() {
282+ input.mu.Lock()
283+ residual := append([]byte(nil), input.residual...)
284+ input.mu.Unlock()
285+ revoked <- residual
286+ <-allowRevocationToReturn
287+ }
288+
289+ // Detach starts reconnect teardown. The residual must already be owned by
290+ // the lifecycle reader at the precise point the old claim is revoked.
291+ first.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
292+ require.Equal(t, []byte("\x1b"), <-revoked)
293+ release()
294+ require.NoError(t, (<-first.done).err)
295+ input.afterRevoke = nil
296+
297+ // The replacement attach receives the retained byte exactly once.
298+ second := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
299+ require.Equal(t, paletteColorBatch, second.nextWrite(t))
300+ _ = second.nextTheme(t)
301+ _ = second.nextTimer(t) // initial palette completion deadline
302+ second.nextTimer(t).fire()
303+ second.nextTimer(t).fire()
304+ got := <-second.transport.inputCh
305+ require.Equal(t, []byte("\x1b"), got.Data)
306+ select {
307+ case duplicate := <-second.transport.inputCh:
308+ require.Failf(t, "duplicate input", "unexpected duplicate %q", duplicate.Data)
309+ default:
310+ }
311+ second.detach(t)
312+}
313+
314+func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
315+ t.Setenv("COLORTERM", "truecolor")
316+ h := startAttachPaletteHarness(&terminalThemeState{})
317+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
318+ initialDeadline := h.nextTimer(t)
319+ h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
320+ _ = h.nextTheme(t)
321+ _ = h.nextTheme(t)
322+ // The initial completion timer was cancelled by the marker; it is distinct
323+ // from both replacement deadlines.
324+ _ = initialDeadline
325+
326+ h.reader.send("\x1b[?997;2n")
327+ cleared := h.nextTheme(t)
328+ require.True(t, cleared.SchemeKnown)
329+ require.True(t, cleared.Light)
330+ require.Zero(t, cleared.PaletteKnown)
331+ require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
332+ drainDeadline := h.nextTimer(t)
333+
334+ // stdin is now blocked in Read. Firing the independently-created drain
335+ // deadline still starts the color batch, and its completion deadline still
336+ // finalizes the partial generation.
337+ drainDeadline.fire()
338+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
339+ completionDeadline := h.nextTimer(t)
340+ completionDeadline.fire()
341+ final := h.nextTheme(t)
342+ require.True(t, final.SchemeKnown)
343+ require.True(t, final.Light)
344+ require.False(t, final.HasForeground)
345+ require.False(t, final.HasBackground)
346+ h.detach(t)
347+ require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots")
348+}
349+
350+func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) {
351+ t.Setenv("COLORTERM", "truecolor")
352+ for _, tc := range []struct {
353+ name string
354+ start func(*terminalInputPump) *attachPaletteHarness
355+ contains string
356+ }{
357+ {
358+ name: "publish",
359+ start: func(input *terminalInputPump) *attachPaletteHarness {
360+ want := errors.New("initial theme send failed")
361+ transport := newAttachPaletteTransport()
362+ transport.themeSendErr = want
363+ return startAttachPaletteHarnessWithInput(&terminalThemeState{}, transport, input)
364+ },
365+ contains: "publishing theme",
366+ },
367+ {
368+ name: "write",
369+ start: func(input *terminalInputPump) *attachPaletteHarness {
370+ return startAttachPaletteHarnessWithInputAndWriteError(&terminalThemeState{}, newAttachPaletteTransport(), input, errors.New("palette write failed"))
371+ },
372+ contains: "writing palette query",
373+ },
374+ } {
375+ t.Run(tc.name, func(t *testing.T) {
376+ input := newTerminalInputPump(nil)
377+ failed := tc.start(input)
378+ err := (<-failed.done).err
379+ require.ErrorContains(t, err, tc.contains)
380+
381+ // The next attach must be able to claim the lifecycle-owned reader.
382+ // Before revocation was registered at claim time this panicked above.
383+ reconnected := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input)
384+ require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
385+ reconnected.detach(t)
386+ })
387+ }
388+}
389+
390+func TestStdinPumpSameReadSchemeAndOldMarkerCannotAdvanceReplacement(t *testing.T) {
391+ ctx, cancel := context.WithCancel(context.Background())
392+ defer cancel()
393+
394+ coordinator := newPaletteGenerationCoordinator()
395+ coordinator.start(ports.Theme{}, false)
396+ var activeGeneration atomic.Uint64
397+ activeGeneration.Store(uint64(coordinator.current.id))
398+
399+ // An unbuffered event channel forces the attach loop to process the scheme
400+ // notification and publish its replacement generation before the scanner
401+ // can deliver the old marker from the same terminal Read.
402+ events := make(chan paletteGenerationEvent)
403+ out := make(chan ports.Frame, 1)
404+ schemeHandled := make(chan struct{})
405+ pump := &stdinPump{
406+ ctx: ctx,
407+ cancel: cancel,
408+ in: strings.NewReader("\x1b[?997;2n\x1b[?2031;1$y"),
409+ out: out,
410+ clock: newAttachPaletteClock(),
411+ logger: slog.New(slog.DiscardHandler),
412+ paletteEvents: events,
413+ activeGeneration: &activeGeneration,
414+ afterPaletteEvent: func(event paletteGenerationEvent) {
415+ if event.kind == paletteEventScheme {
416+ <-schemeHandled
417+ }
418+ },
419+ }
420+ pumpDone := make(chan struct{})
421+ go func() {
422+ pump.run()
423+ close(pumpDone)
424+ }()
425+
426+ scheme := <-events
427+ require.Equal(t, paletteEventScheme, scheme.kind)
428+ oldID := scheme.id
429+
430+ // This models attachAttempt handling the scheme event. The replacement is
431+ // draining, so a marker tagged with its ID would incorrectly start its
432+ // color batch before the real drain boundary arrives.
433+ coordinator.start(ports.Theme{SchemeKnown: true, Light: scheme.light}, true)
434+ newID := coordinator.current.id
435+ activeGeneration.Store(uint64(newID))
436+ close(schemeHandled)
437+ require.NotEqual(t, oldID, newID)
438+ require.Equal(t, generationDraining, coordinator.current.phase)
439+
440+ oldMarker := <-events
441+ require.Equal(t, paletteEventMarker, oldMarker.kind)
442+ require.Equal(t, oldID, oldMarker.id, "one read must retain its generation tag")
443+ require.Empty(t, coordinator.handle(oldMarker))
444+ require.True(t, coordinator.active)
445+ require.Equal(t, generationDraining, coordinator.current.phase)
446+ require.Equal(t, newID, coordinator.current.id)
447+
448+ <-pumpDone
449+}
450+
451+func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
452+ t.Setenv("COLORTERM", "truecolor")
453+ h := startAttachPaletteHarness(&terminalThemeState{})
454+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
455+ _ = h.nextTimer(t)
456+ h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
457+ _ = h.nextTheme(t)
458+ _ = h.nextTheme(t)
459+
460+ h.reader.send("\x1b[?997;1n")
461+ _ = h.nextTheme(t)
462+ require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
463+ drainDeadline := h.nextTimer(t)
464+ drainDeadline.fire()
465+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
466+ completionDeadline := h.nextTimer(t)
467+
468+ // The drain's stale reports arrive before its late boundary and must not
469+ // contaminate the replacement accumulator. Reports after that boundary are
470+ // the batch's reports and are collected through the second marker.
471+ h.reader.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
472+ final := h.nextTheme(t)
473+ require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
474+ require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
475+ require.Equal(t, uint16(1<<3), final.PaletteKnown)
476+ // The marker finalization cancels, rather than fires, the independently
477+ // armed completion deadline.
478+ _ = completionDeadline
479+ h.detach(t)
480+}
481+
482+func TestAttachForwardsStandaloneEscapeAfterAmbiguityDeadline(t *testing.T) {
483+ t.Setenv("COLORTERM", "truecolor")
484+ h := startAttachPaletteHarness(&terminalThemeState{})
485+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
486+ _ = h.nextTimer(t) // initial palette completion deadline
487+ _ = h.nextTheme(t) // initial cleared publication
488+
489+ // A bare Escape is ambiguous with the prefix of the DECRQM completion
490+ // response. It must not require another Read or EOF before reaching the
491+ // daemon.
492+ h.reader.send("\x1b")
493+ h.nextTimer(t).fire() // DECRQM ambiguity deadline
494+ h.nextTimer(t).fire() // bracketed-paste ambiguity deadline
495+ input := <-h.transport.inputCh
496+ require.Equal(t, []byte("\x1b"), input.Data)
497+ h.detach(t)
498+}
499+
500+func TestAttachConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) {
501+ t.Setenv("COLORTERM", "truecolor")
502+ h := startAttachPaletteHarness(&terminalThemeState{})
503+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
504+ _ = h.nextTimer(t) // initial palette completion deadline
505+ _ = h.nextTheme(t) // initial cleared publication
506+
507+ h.reader.send("before\x1b[?2031;")
508+ markerDeadline := h.nextTimer(t)
509+ h.reader.send("1$yafter")
510+
511+ final := h.nextTheme(t)
512+ require.False(t, final.HasForeground)
513+ var got []byte
514+ for len(got) < len("beforeafter") {
515+ got = append(got, (<-h.transport.inputCh).Data...)
516+ }
517+ require.Equal(t, []byte("beforeafter"), got)
518+ <-markerDeadline.stopped
519+ h.detach(t)
520+}
521+
522+func TestAttachForwardsInputOnceAndPartialOSCDoesNotPublishTheme(t *testing.T) {
523+ t.Setenv("COLORTERM", "truecolor")
524+ h := startAttachPaletteHarness(&terminalThemeState{})
525+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
526+ _ = h.nextTimer(t)
527+ cleared := h.nextTheme(t)
528+ require.Zero(t, cleared.PaletteKnown)
529+
530+ h.reader.send("\x1b]10;#0102")
531+ h.reader.send("03\az")
532+ input := <-h.transport.inputCh
533+ require.Equal(t, []byte("z"), input.Data)
534+ require.Len(t, h.transport.snapshots(), 1, "partial and incomplete OSC replies must not publish Theme")
535+
536+ h.reader.send("\x1b[?2031;1$y")
537+ final := h.nextTheme(t)
538+ require.True(t, final.HasForeground)
539+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
540+ require.Len(t, h.transport.snapshots(), 2)
541+ h.detach(t)
542+}
A internal/usecase/client/palette_attach_test.go +169 -0
@@ -0,0 +1,169 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+type paletteAttachClock struct{}
20+
21+func (paletteAttachClock) Now() time.Time { return time.Time{} }
22+func (paletteAttachClock) NewTimer(time.Duration) ports.Timer {
23+ return paletteAttachTimer{ch: make(chan time.Time)}
24+}
25+
26+type paletteAttachTimer struct{ ch chan time.Time }
27+
28+func (t paletteAttachTimer) C() <-chan time.Time { return t.ch }
29+func (paletteAttachTimer) Reset(time.Duration) bool { return false }
30+func (paletteAttachTimer) Stop() bool { return true }
31+
32+type paletteAttachReader struct {
33+ mu sync.Mutex
34+ data []byte
35+ done chan struct{}
36+ once sync.Once
37+}
38+
39+func newPaletteAttachReader(data []byte) *paletteAttachReader {
40+ return &paletteAttachReader{data: append([]byte(nil), data...), done: make(chan struct{})}
41+}
42+
43+// Read retains unread bytes after a short read and blocks only until close.
44+// This keeps the test terminal reader faithful to io.Reader and makes a
45+// failed test teardown deterministic instead of leaving a goroutine parked.
46+func (r *paletteAttachReader) Read(p []byte) (int, error) {
47+ r.mu.Lock()
48+ if len(r.data) != 0 {
49+ n := copy(p, r.data)
50+ r.data = r.data[n:]
51+ r.mu.Unlock()
52+ return n, nil
53+ }
54+ r.mu.Unlock()
55+ <-r.done
56+ return 0, io.EOF
57+}
58+
59+func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
60+
61+type paletteAttachTerminal struct {
62+ in io.Reader
63+ out bytes.Buffer
64+ resize chan domain.Size
65+}
66+
67+func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
68+ return func() error { return nil }, nil
69+}
70+func (t *paletteAttachTerminal) Size() (domain.Size, error) {
71+ return domain.Size{Cols: 80, Rows: 24}, nil
72+}
73+func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }
74+func (t *paletteAttachTerminal) In() io.Reader { return t.in }
75+func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
76+func (t *paletteAttachTerminal) Flush() error { return nil }
77+
78+type paletteAttachTransport struct {
79+ mu sync.Mutex
80+ sent []ports.Frame
81+ themes []ports.Theme
82+ finals int
83+ finalSet chan struct{}
84+}
85+
86+func (t *paletteAttachTransport) Send(frame ports.Frame) error {
87+ t.mu.Lock()
88+ defer t.mu.Unlock()
89+ t.sent = append(t.sent, frame)
90+ if frame.Type == ports.MsgTheme {
91+ theme, err := ports.UnmarshalTheme(frame.Payload)
92+ if err != nil {
93+ return err
94+ }
95+ t.themes = append(t.themes, theme)
96+ if theme.HasForeground && theme.HasBackground {
97+ t.finals++
98+ if t.finals == 1 {
99+ close(t.finalSet)
100+ }
101+ }
102+ }
103+ return nil
104+}
105+
106+func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
107+ t.mu.Lock()
108+ first := len(t.sent) == 1
109+ t.mu.Unlock()
110+ if first {
111+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
112+ }
113+ <-t.finalSet
114+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}, nil
115+}
116+func (*paletteAttachTransport) Close() error { return nil }
117+
118+func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
119+ reader := newPaletteAttachReader([]byte("abcdef"))
120+ first := make([]byte, 2)
121+ n, err := reader.Read(first)
122+ require.NoError(t, err)
123+ require.Equal(t, "ab", string(first[:n]))
124+
125+ rest := make([]byte, 8)
126+ n, err = reader.Read(rest)
127+ require.NoError(t, err)
128+ require.Equal(t, "cdef", string(rest[:n]))
129+
130+ reader.close()
131+ n, err = reader.Read(rest)
132+ require.Zero(t, n)
133+ require.ErrorIs(t, err, io.EOF)
134+}
135+
136+func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
137+ input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138+ t.Cleanup(input.close)
139+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
140+ transport := &paletteAttachTransport{finalSet: make(chan struct{})}
141+ ms := &milestones{}
142+ runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
143+ attempt := &attachAttempt{
144+ runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
145+ milestones: ms, themeState: &terminalThemeState{},
146+ enterRaw: func() error { ms.rawEntered = true; return nil },
147+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
148+ }
149+
150+ result := attempt.run(context.Background())
151+ require.NoError(t, result.err)
152+ require.Equal(t, paletteColorBatch, term.out.String())
153+
154+ transport.mu.Lock()
155+ defer transport.mu.Unlock()
156+ require.Len(t, transport.sent, 3)
157+ require.Equal(t, []ports.MsgType{ports.MsgHello, ports.MsgTheme, ports.MsgTheme}, []ports.MsgType{
158+ transport.sent[0].Type, transport.sent[1].Type, transport.sent[2].Type,
159+ }, "the protocol requires Hello before the two palette publications")
160+ require.Len(t, transport.themes, 2)
161+ cleared, definitive := transport.themes[0], transport.themes[1]
162+ require.Zero(t, cleared.PaletteKnown)
163+ require.False(t, cleared.HasForeground)
164+ require.True(t, definitive.HasForeground)
165+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, definitive.Foreground)
166+ require.True(t, definitive.HasBackground)
167+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, definitive.Background)
168+ require.Equal(t, uint16(1<<2), definitive.PaletteKnown)
169+}
A internal/usecase/client/palette_generation.go +320 -0
@@ -0,0 +1,320 @@
1+package client
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// Palette queries always have a DECRQM boundary. The first marker drains
11+// replies from a replaced generation; the second completes the color batch.
12+const paletteBoundaryQuery = "\x1b[?2031$p"
13+
14+const paletteColorBatch = "\x1b]10;?\x07\x1b]11;?\x07" +
15+ "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" +
16+ paletteBoundaryQuery
17+
18+// paletteGenerationDeadline is a local terminal capability fallback, not a
19+// user-facing timeout. Timer ownership belongs to the attach loop.
20+const paletteGenerationDeadline = 200 * time.Millisecond
21+
22+// paletteMarkerAmbiguityDeadline bounds how long an Escape byte may remain
23+// withheld while it could still become a DECRQM completion response. Match
24+// the existing local input-prefix bound so a keypress remains prompt.
25+const paletteMarkerAmbiguityDeadline = pasteFlushDelay
26+
27+type paletteGenerationID uint64
28+
29+type paletteGenerationPhase uint8
30+
31+const (
32+ generationIdle paletteGenerationPhase = iota
33+ generationDraining
34+ generationCollecting
35+ generationFinalized
36+)
37+
38+type paletteGeneration struct {
39+ id paletteGenerationID
40+ phase paletteGenerationPhase
41+ theme ports.Theme
42+ expectedMarkers int
43+ awaitingLateDrainBoundary bool
44+}
45+
46+type paletteGenerationEventKind uint8
47+
48+const (
49+ paletteEventMarker paletteGenerationEventKind = iota
50+ paletteEventDrainDeadline
51+ paletteEventCompletionDeadline
52+ paletteEventForeground
53+ paletteEventBackground
54+ paletteEventPalette
55+ paletteEventScheme
56+)
57+
58+type paletteGenerationEvent struct {
59+ id paletteGenerationID
60+ kind paletteGenerationEventKind
61+ rgb renderer.RGB
62+ slot uint8
63+ light bool
64+}
65+
66+// paletteGenerationAction is deliberately declarative. The attach loop is
67+// the sole terminal writer and protocol sender; timer bridges only enqueue a
68+// generation-tagged event back to that loop.
69+type paletteGenerationActionKind uint8
70+
71+const (
72+ paletteActionPublishCleared paletteGenerationActionKind = iota
73+ paletteActionWriteDrain
74+ paletteActionWriteBatch
75+ paletteActionArmDrainDeadline
76+ paletteActionArmCompletionDeadline
77+ paletteActionCancelDrainDeadline
78+ paletteActionCancelCompletionDeadline
79+ paletteActionPublishFinal
80+)
81+
82+type paletteGenerationAction struct {
83+ kind paletteGenerationActionKind
84+ id paletteGenerationID
85+ bytes string
86+ theme ports.Theme
87+ deadline time.Duration
88+}
89+
90+// paletteGenerationCoordinator has no I/O, clocks, channels, or goroutines.
91+// All state is scoped to one monotonically increasing generation.
92+type paletteGenerationCoordinator struct {
93+ next paletteGenerationID
94+ current paletteGeneration
95+ active bool
96+ finalized ports.Theme
97+}
98+
99+func newPaletteGenerationCoordinator() *paletteGenerationCoordinator {
100+ return &paletteGenerationCoordinator{}
101+}
102+
103+// start clears the last definitive palette before issuing either the initial
104+// batch or a replacement drain. The retained foreground/background and
105+// capability avoid a full neutral flash, while the accumulator begins with no
106+// defaults or palette values so a definitive result never mixes generations.
107+func (c *paletteGenerationCoordinator) start(retained ports.Theme, replacement bool) []paletteGenerationAction {
108+ actions := c.cancelCurrent()
109+ c.next++
110+ id := c.next
111+
112+ cleared := retained
113+ cleared.PaletteKnown = 0
114+ cleared.Palette = [16]renderer.RGB{}
115+ c.current = paletteGeneration{
116+ id: id,
117+ phase: generationCollecting,
118+ theme: ports.Theme{
119+ TrueColor: retained.TrueColor,
120+ SchemeKnown: retained.SchemeKnown,
121+ Light: retained.Light,
122+ },
123+ }
124+ c.active = true
125+ actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared})
126+ if replacement {
127+ c.current.phase = generationDraining
128+ actions = append(actions,
129+ paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery},
130+ paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline},
131+ )
132+ return actions
133+ }
134+ return append(actions,
135+ paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch},
136+ paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline},
137+ )
138+}
139+
140+func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction {
141+ if !c.active {
142+ return nil
143+ }
144+ id := c.current.id
145+ c.active = false
146+ c.current.phase = generationFinalized
147+ return []paletteGenerationAction{
148+ {kind: paletteActionCancelDrainDeadline, id: id},
149+ {kind: paletteActionCancelCompletionDeadline, id: id},
150+ }
151+}
152+
153+func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction {
154+ if !c.active || event.id != c.current.id {
155+ return nil
156+ }
157+ switch event.kind {
158+ case paletteEventMarker:
159+ return c.onMarker()
160+ case paletteEventDrainDeadline:
161+ if c.current.phase != generationDraining {
162+ return nil
163+ }
164+ c.current.phase = generationCollecting
165+ // A late drain response may still arrive after the batch marker. Exclude
166+ // OSC reports until its boundary is consumed: otherwise stale drain-era
167+ // reports could become part of this generation's definitive snapshot.
168+ c.current.expectedMarkers = 2
169+ c.current.awaitingLateDrainBoundary = true
170+ return []paletteGenerationAction{
171+ {kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch},
172+ {kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline},
173+ }
174+ case paletteEventCompletionDeadline:
175+ if c.current.phase != generationCollecting {
176+ return nil
177+ }
178+ return c.finalize()
179+ case paletteEventForeground:
180+ if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
181+ c.current.theme.HasForeground = true
182+ c.current.theme.Foreground = event.rgb
183+ }
184+ case paletteEventBackground:
185+ if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
186+ c.current.theme.HasBackground = true
187+ c.current.theme.Background = event.rgb
188+ }
189+ case paletteEventPalette:
190+ if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary && event.slot < 16 {
191+ c.current.theme.PaletteKnown |= uint16(1) << event.slot
192+ c.current.theme.Palette[event.slot] = event.rgb
193+ }
194+ }
195+ return nil
196+}
197+
198+func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction {
199+ switch c.current.phase {
200+ case generationDraining:
201+ c.current.phase = generationCollecting
202+ c.current.expectedMarkers = 1
203+ return []paletteGenerationAction{
204+ {kind: paletteActionCancelDrainDeadline, id: c.current.id},
205+ {kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch},
206+ {kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline},
207+ }
208+ case generationCollecting:
209+ if c.current.expectedMarkers == 0 {
210+ c.current.expectedMarkers = 1
211+ }
212+ if c.current.awaitingLateDrainBoundary {
213+ c.current.awaitingLateDrainBoundary = false
214+ }
215+ c.current.expectedMarkers--
216+ if c.current.expectedMarkers == 0 {
217+ return c.finalize()
218+ }
219+ }
220+ return nil
221+}
222+
223+func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
224+ if !c.active || c.current.phase != generationCollecting {
225+ return nil
226+ }
227+ c.current.phase = generationFinalized
228+ c.active = false
229+ c.finalized = c.current.theme
230+ return []paletteGenerationAction{
231+ {kind: paletteActionCancelDrainDeadline, id: c.current.id},
232+ {kind: paletteActionCancelCompletionDeadline, id: c.current.id},
233+ {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
234+ }
235+}
236+
237+func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
238+ return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
239+}
240+
241+func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
242+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
243+}
244+
245+func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
246+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
247+}
248+
249+func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
250+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
251+}
252+
253+func (c *paletteGenerationCoordinator) finalizedTheme() ports.Theme { return c.finalized }
254+
255+// paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
256+// possible prefixes across reads, forwarding all ordinary input exactly once.
257+type paletteMarkerScanner struct{ pending []byte }
258+
259+var paletteMarkerResponses = [][]byte{
260+ []byte("\x1b[?2031;0$y"),
261+ []byte("\x1b[?2031;1$y"),
262+ []byte("\x1b[?2031;2$y"),
263+ []byte("\x1b[?2031;3$y"),
264+ []byte("\x1b[?2031;4$y"),
265+}
266+
267+func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
268+ for i, b := range data {
269+ s.pending = append(s.pending, b)
270+ matched, possible := false, false
271+ for _, response := range paletteMarkerResponses {
272+ if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
273+ continue
274+ }
275+ if len(s.pending) == len(response) {
276+ matched = true
277+ } else {
278+ possible = true
279+ }
280+ }
281+ if matched {
282+ s.pending = nil
283+ onMarker()
284+ continue
285+ }
286+ if possible {
287+ continue
288+ }
289+ if s.pending[0] != '\x1b' {
290+ onBytes(s.pending[:1])
291+ s.pending = s.pending[1:]
292+ continue
293+ }
294+ // This is not a marker. Forward the entire remaining read as one
295+ // contiguous input run so CSI mouse reports and escape sequences keep
296+ // their framing.
297+ s.pending = append(s.pending, data[i+1:]...)
298+ onBytes(s.pending)
299+ s.pending = nil
300+ return
301+ }
302+}
303+func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
304+ if len(s.pending) == 0 {
305+ return
306+ }
307+ onBytes(s.pending)
308+ s.pending = nil
309+}
310+
311+// takePending transfers only an undecided DECRQM prefix to a replacement
312+// scanner. Callers use it during cancellation after all other callbacks from
313+// the source read have been committed, preventing both loss and replay.
314+func (s *paletteMarkerScanner) takePending() []byte {
315+ pending := append([]byte(nil), s.pending...)
316+ s.pending = nil
317+ return pending
318+}
319+
320+func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }
A internal/usecase/client/palette_generation_test.go +248 -0
@@ -0,0 +1,248 @@
1+package client
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+// manualPaletteClock applies only declarative coordinator timer actions. Its
14+// callbacks re-enter the coordinator through generation-tagged events; they
15+// never perform terminal I/O or protocol sends.
16+type manualPaletteClock struct {
17+ drain, completion map[paletteGenerationID]bool
18+}
19+
20+func newManualPaletteClock() *manualPaletteClock {
21+ return &manualPaletteClock{drain: map[paletteGenerationID]bool{}, completion: map[paletteGenerationID]bool{}}
22+}
23+
24+func (c *manualPaletteClock) apply(actions []paletteGenerationAction) {
25+ for _, action := range actions {
26+ switch action.kind {
27+ case paletteActionArmDrainDeadline:
28+ c.drain[action.id] = true
29+ case paletteActionArmCompletionDeadline:
30+ c.completion[action.id] = true
31+ case paletteActionCancelDrainDeadline:
32+ delete(c.drain, action.id)
33+ case paletteActionCancelCompletionDeadline:
34+ delete(c.completion, action.id)
35+ }
36+ }
37+}
38+
39+func (c *manualPaletteClock) fireDrain(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction {
40+ if !c.drain[id] {
41+ return nil
42+ }
43+ delete(c.drain, id)
44+ return g.handle(paletteGenerationEvent{id: id, kind: paletteEventDrainDeadline})
45+}
46+
47+func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction {
48+ if !c.completion[id] {
49+ return nil
50+ }
51+ delete(c.completion, id)
52+ return g.handle(paletteGenerationEvent{id: id, kind: paletteEventCompletionDeadline})
53+}
54+
55+func generationTheme() ports.Theme {
56+ return ports.Theme{
57+ TrueColor: true,
58+ HasForeground: true,
59+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
60+ HasBackground: true,
61+ Background: renderer.RGB{R: 4, G: 5, B: 6},
62+ SchemeKnown: true,
63+ Light: true,
64+ PaletteKnown: 1 << 2,
65+ Palette: [16]renderer.RGB{2: {R: 7, G: 8, B: 9}},
66+ }
67+}
68+
69+func actionKinds(actions []paletteGenerationAction) []paletteGenerationActionKind {
70+ out := make([]paletteGenerationActionKind, len(actions))
71+ for i := range actions {
72+ out[i] = actions[i].kind
73+ }
74+ return out
75+}
76+
77+func findAction(t *testing.T, actions []paletteGenerationAction, kind paletteGenerationActionKind) paletteGenerationAction {
78+ t.Helper()
79+ for _, action := range actions {
80+ if action.kind == kind {
81+ return action
82+ }
83+ }
84+ t.Fatalf("missing action %v in %#v", kind, actions)
85+ return paletteGenerationAction{}
86+}
87+
88+func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
89+ g := newPaletteGenerationCoordinator()
90+ clock := newManualPaletteClock()
91+ start := g.start(generationTheme(), false)
92+ clock.apply(start)
93+
94+ require.Equal(t, []paletteGenerationActionKind{
95+ paletteActionPublishCleared,
96+ paletteActionWriteBatch,
97+ paletteActionArmCompletionDeadline,
98+ }, actionKinds(start))
99+ cleared := findAction(t, start, paletteActionPublishCleared).theme
100+ require.True(t, cleared.HasForeground)
101+ require.Equal(t, generationTheme().Foreground, cleared.Foreground)
102+ require.Zero(t, cleared.PaletteKnown)
103+ require.Zero(t, cleared.Palette)
104+
105+ batch := findAction(t, start, paletteActionWriteBatch)
106+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07"+
107+ "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"+
108+ "\x1b[?2031$p", batch.bytes)
109+ id := batch.id
110+ g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12})
111+ g.background(id, renderer.RGB{R: 13, G: 14, B: 15})
112+ g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18})
113+ final := g.marker(id)
114+ clock.apply(final)
115+
116+ require.Equal(t, []paletteGenerationActionKind{
117+ paletteActionCancelDrainDeadline,
118+ paletteActionCancelCompletionDeadline,
119+ paletteActionPublishFinal,
120+ }, actionKinds(final))
121+ got := findAction(t, final, paletteActionPublishFinal).theme
122+ require.True(t, got.TrueColor)
123+ require.True(t, got.HasForeground)
124+ require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground)
125+ require.True(t, got.HasBackground)
126+ require.Equal(t, uint16(1<<3), got.PaletteKnown)
127+}
128+
129+func TestPaletteGenerationReplacementWaitsForDrain(t *testing.T) {
130+ g := newPaletteGenerationCoordinator()
131+ clock := newManualPaletteClock()
132+ start := g.start(generationTheme(), true)
133+ clock.apply(start)
134+
135+ require.Equal(t, []paletteGenerationActionKind{
136+ paletteActionPublishCleared,
137+ paletteActionWriteDrain,
138+ paletteActionArmDrainDeadline,
139+ }, actionKinds(start))
140+ drain := findAction(t, start, paletteActionWriteDrain)
141+ require.Equal(t, "\x1b[?2031$p", drain.bytes)
142+ id := drain.id
143+ batch := g.marker(id)
144+ clock.apply(batch)
145+ require.Equal(t, []paletteGenerationActionKind{
146+ paletteActionCancelDrainDeadline,
147+ paletteActionWriteBatch,
148+ paletteActionArmCompletionDeadline,
149+ }, actionKinds(batch))
150+}
151+
152+func TestPaletteGenerationLateDrainBoundaryExcludesStaleOSC(t *testing.T) {
153+ g := newPaletteGenerationCoordinator()
154+ clock := newManualPaletteClock()
155+ start := g.start(generationTheme(), true)
156+ clock.apply(start)
157+ id := findAction(t, start, paletteActionWriteDrain).id
158+
159+ timedOut := clock.fireDrain(g, id)
160+ clock.apply(timedOut)
161+ require.Equal(t, []paletteGenerationActionKind{
162+ paletteActionWriteBatch,
163+ paletteActionArmCompletionDeadline,
164+ }, actionKinds(timedOut))
165+
166+ // The drain reply may be late, so reports received before its boundary are
167+ // conservatively excluded from this generation's accumulator.
168+ g.foreground(id, renderer.RGB{R: 1, G: 2, B: 3})
169+ g.background(id, renderer.RGB{R: 4, G: 5, B: 6})
170+ g.palette(id, 2, renderer.RGB{R: 7, G: 8, B: 9})
171+
172+ lateDrain := g.marker(id)
173+ require.Empty(t, lateDrain, "the late drain response must not finalize the batch")
174+ g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12})
175+ g.background(id, renderer.RGB{R: 13, G: 14, B: 15})
176+ g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18})
177+ complete := g.marker(id)
178+ clock.apply(complete)
179+ final := findAction(t, complete, paletteActionPublishFinal).theme
180+ require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, final.Foreground)
181+ require.Equal(t, renderer.RGB{R: 13, G: 14, B: 15}, final.Background)
182+ require.Equal(t, uint16(1<<3), final.PaletteKnown)
183+ require.Equal(t, renderer.RGB{R: 16, G: 17, B: 18}, final.Palette[3])
184+}
185+
186+func TestPaletteGenerationCompletionDeadlineFinalizesAfterMissingLateDrain(t *testing.T) {
187+ g := newPaletteGenerationCoordinator()
188+ clock := newManualPaletteClock()
189+ start := g.start(generationTheme(), true)
190+ clock.apply(start)
191+ id := findAction(t, start, paletteActionWriteDrain).id
192+ clock.apply(clock.fireDrain(g, id))
193+
194+ // One marker may be the completion marker while the timed-out drain reply
195+ // never arrives, so it cannot finalize by marker accounting alone.
196+ require.Empty(t, g.marker(id))
197+ final := clock.fireCompletion(g, id)
198+ clock.apply(final)
199+ require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind)
200+}
201+
202+func TestPaletteGenerationStaleTimerAndMarkerAreNoOps(t *testing.T) {
203+ g := newPaletteGenerationCoordinator()
204+ clock := newManualPaletteClock()
205+ first := g.start(generationTheme(), true)
206+ clock.apply(first)
207+ oldID := findAction(t, first, paletteActionWriteDrain).id
208+ second := g.start(generationTheme(), false)
209+ clock.apply(second)
210+ newID := findAction(t, second, paletteActionWriteBatch).id
211+
212+ require.Empty(t, g.marker(oldID))
213+ require.Empty(t, clock.fireDrain(g, oldID))
214+ require.Empty(t, clock.fireCompletion(g, oldID))
215+ final := g.marker(newID)
216+ require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind)
217+}
218+
219+func TestPaletteMarkerScannerConsumesSplitMarkerAndPreservesInput(t *testing.T) {
220+ scanner := paletteMarkerScanner{}
221+ var got []byte
222+ markers := 0
223+ emit := func(data []byte) { got = append(got, data...) }
224+ marker := func() { markers++ }
225+
226+ scanner.scan([]byte("a\x1b[?2031;"), emit, marker)
227+ scanner.scan([]byte("2$yb"), emit, marker)
228+ scanner.flush(emit)
229+
230+ require.Equal(t, []byte("ab"), got)
231+ require.Equal(t, 1, markers)
232+}
233+
234+func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T) {
235+ g := newPaletteGenerationCoordinator()
236+ start := g.start(generationTheme(), false)
237+ id := findAction(t, start, paletteActionWriteBatch).id
238+ g.palette(id, 2, renderer.RGB{R: 1, G: 2, B: 3})
239+ final := g.marker(id)
240+ want := findAction(t, final, paletteActionPublishFinal).theme
241+
242+ g.palette(id, 2, renderer.RGB{R: 9, G: 9, B: 9})
243+ require.Equal(t, want, g.finalizedTheme())
244+}
245+
246+func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) {
247+ require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline)
248+}
M internal/usecase/client/paste_coalescer.go +15 -3
@@ -158,15 +158,27 @@ func (c *pasteCoalescer) Buffering() bool {
158158 return c.buffering || len(c.pending) > 0
159159 }
160160
161-// Close stops any pending timers and their goroutines. Held bytes are dropped:
162-// Close runs only as the stdin pump unwinds on detach, when there is nowhere
163-// left to deliver them.
161+// Close stops any pending timers and their goroutines. Callers which hand input
162+// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
164163 func (c *pasteCoalescer) Close() {
164+ _ = c.CloseAndTakeHeld()
165+}
166+
167+// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
168+// deciding whether an opening marker or complete bracketed paste would arrive.
169+// The returned bytes retain their original order and are removed from c.
170+func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
165171 c.mu.Lock()
166172 defer c.mu.Unlock()
167173 c.closed = true
168174 c.stopFlushTimer()
169175 c.stopSafetyTimer()
176+ held := append([]byte(nil), c.pending...)
177+ held = append(held, c.buf...)
178+ c.pending = nil
179+ c.buf = nil
180+ c.buffering = false
181+ return held
170182 }
171183
172184 func (c *pasteCoalescer) startFlushTimer() {
M internal/usecase/client/reconnect_toast_test.go +64 -183
@@ -43,7 +43,6 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
4343 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
4444 h.term.EXPECT().Size().Return(h.size, nil).Maybe()
4545 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
46- h.term.EXPECT().QueryColors().Return(nil).Maybe()
4746 h.term.EXPECT().In().Return(h.in).Maybe()
4847 h.term.EXPECT().Out().Return(&h.out).Maybe()
4948 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -63,7 +62,6 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re
6362 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
6463 h.term.EXPECT().Size().Return(h.size, nil).Maybe()
6564 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
66- h.term.EXPECT().QueryColors().Return(nil).Maybe()
6765 h.term.EXPECT().In().Return(h.in).Maybe()
6866 h.term.EXPECT().Out().Return(out).Maybe()
6967 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -112,18 +110,6 @@ func reconnectToastDetach(reason uint8) ports.Frame {
112110 return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
113111 }
114112
115-func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
116- t.Helper()
117- tr := portsmocks.NewMockTransport(t)
118- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
119- for _, recv := range recvs {
120- tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
121- }
122- tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
123- tr.EXPECT().Close().Return(nil).Maybe()
124- return tr
125-}
126-
127113 type reconnectToastRecv struct {
128114 frame ports.Frame
129115 err error
@@ -144,11 +130,16 @@ func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, e
144130 }
145131
146132 type reconnectToastRecordingTransport struct {
133+ mu sync.Mutex
147134 recvs []reconnectToastRecv
148135 sends []ports.Frame
149136 closed bool
150137 }
151138
139+func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnectToastRecordingTransport {
140+ return &reconnectToastRecordingTransport{recvs: append([]reconnectToastRecv(nil), recvs...)}
141+}
142+
152143 type reconnectToastLinkTransport struct {
153144 recvCh chan reconnectToastRecv
154145 sends []ports.Frame
@@ -181,11 +172,15 @@ func (t *reconnectToastLinkTransport) LinkEvents() <-chan ports.LinkEvent { retu
181172 func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state }
182173
183174 func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
175+ t.mu.Lock()
176+ defer t.mu.Unlock()
184177 t.sends = append(t.sends, f)
185178 return nil
186179 }
187180
188181 func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
182+ t.mu.Lock()
183+ defer t.mu.Unlock()
189184 if len(t.recvs) == 0 {
190185 return ports.Frame{}, io.EOF
191186 }
@@ -195,15 +190,47 @@ func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
195190 }
196191
197192 func (t *reconnectToastRecordingTransport) Close() error {
193+ t.mu.Lock()
194+ defer t.mu.Unlock()
198195 t.closed = true
199196 return nil
200197 }
201198
199+func (t *reconnectToastRecordingTransport) sentFrames() []ports.Frame {
200+ t.mu.Lock()
201+ defer t.mu.Unlock()
202+ return append([]ports.Frame(nil), t.sends...)
203+}
204+
205+func (t *reconnectToastRecordingTransport) wasClosed() bool {
206+ t.mu.Lock()
207+ defer t.mu.Unlock()
208+ return t.closed
209+}
210+
211+// assertReconnectToastAttemptPublishesOnlyClearedTheme verifies the precise
212+// handshake publication for these no-OSC-response reconnect paths. A final
213+// Theme would be wrong here: no terminal palette response was supplied.
214+func assertReconnectToastAttemptPublishesOnlyClearedTheme(t *testing.T, tr *reconnectToastRecordingTransport) {
215+ t.Helper()
216+ frames := tr.sentFrames()
217+ require.Len(t, frames, 2)
218+ require.Equal(t, ports.MsgHello, frames[0].Type)
219+ require.Equal(t, ports.MsgTheme, frames[1].Type)
220+ theme, err := ports.UnmarshalTheme(frames[1].Payload)
221+ require.NoError(t, err)
222+ require.False(t, theme.HasForeground)
223+ require.False(t, theme.HasBackground)
224+ require.Zero(t, theme.PaletteKnown)
225+ require.True(t, tr.wasClosed())
226+}
227+
202228 func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
203229 t.Helper()
204- require.NotEmpty(t, tr.sends)
205- require.Equal(t, ports.MsgHello, tr.sends[0].Type)
206- hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
230+ frames := tr.sentFrames()
231+ require.NotEmpty(t, frames)
232+ require.Equal(t, ports.MsgHello, frames[0].Type)
233+ hello, err := ports.UnmarshalHello(frames[0].Payload)
207234 require.NoError(t, err)
208235 return hello
209236 }
@@ -217,6 +244,8 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock {
217244 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()
218245 timer.EXPECT().Stop().Return(true).Maybe()
219246 clk.EXPECT().NewTimer(preWelcomeTimeout).Return(timer).Maybe()
247+ // Every successful attach starts an initial palette-generation deadline.
248+ clk.EXPECT().NewTimer(paletteGenerationDeadline).Return(timer).Maybe()
220249 return clk
221250 }
222251
@@ -493,8 +522,8 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
493522
494523 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
495524 require.NoError(t, err)
496- require.True(t, tr1.closed)
497- require.True(t, tr2.closed)
525+ require.True(t, tr1.wasClosed())
526+ require.True(t, tr2.wasClosed())
498527 require.Equal(t, 2, dialer.calls)
499528
500529 firstHello := reconnectToastHelloFromSend(t, tr1)
@@ -510,160 +539,6 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
510539 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
511540 }
512541
513-type reconnectThemeTransport struct {
514- resumeToken uint64
515- disconnectAfterTheme bool
516- recvCount int
517- fullTheme chan ports.Theme
518- observedTheme ports.Theme
519-}
520-
521-func newReconnectThemeTransport(token uint64, disconnectAfterTheme bool) *reconnectThemeTransport {
522- return &reconnectThemeTransport{
523- resumeToken: token,
524- disconnectAfterTheme: disconnectAfterTheme,
525- fullTheme: make(chan ports.Theme, 1),
526- }
527-}
528-
529-func (t *reconnectThemeTransport) Send(f ports.Frame) error {
530- if f.Type != ports.MsgTheme {
531- return nil
532- }
533- theme, err := ports.UnmarshalTheme(f.Payload)
534- if err != nil {
535- return err
536- }
537- if theme.HasForeground && theme.HasBackground {
538- select {
539- case t.fullTheme <- theme:
540- default:
541- }
542- }
543- return nil
544-}
545-
546-func (t *reconnectThemeTransport) Recv() (ports.Frame, error) {
547- t.recvCount++
548- if t.recvCount == 1 {
549- return reconnectToastWelcome(t.resumeToken), nil
550- }
551- t.observedTheme = <-t.fullTheme
552- if t.disconnectAfterTheme {
553- return ports.Frame{}, io.EOF
554- }
555- return reconnectToastDetach(ports.ReasonDetach), nil
556-}
557-
558-func (t *reconnectThemeTransport) Close() error { return nil }
559-
560-type blockingThemeRestoreTransport struct {
561- started chan struct{}
562- closed chan struct{}
563- recvs int
564-}
565-
566-func newBlockingThemeRestoreTransport() *blockingThemeRestoreTransport {
567- return &blockingThemeRestoreTransport{started: make(chan struct{}), closed: make(chan struct{})}
568-}
569-
570-func (t *blockingThemeRestoreTransport) Send(f ports.Frame) error {
571- if f.Type != ports.MsgTheme {
572- return nil
573- }
574- close(t.started)
575- <-t.closed
576- return io.ErrClosedPipe
577-}
578-
579-func (t *blockingThemeRestoreTransport) Recv() (ports.Frame, error) {
580- t.recvs++
581- if t.recvs == 1 {
582- return reconnectToastWelcome(44), nil
583- }
584- <-t.closed
585- return ports.Frame{}, io.EOF
586-}
587-
588-func (t *blockingThemeRestoreTransport) Close() error {
589- select {
590- case <-t.closed:
591- default:
592- close(t.closed)
593- }
594- return nil
595-}
596-
597-func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
598- term := newReconnectToastTerminalHarness(t)
599- defer term.closeInput()
600- themeState := &terminalThemeState{}
601- themeState.setTrueColor(true)
602- themeState.update(func(theme *ports.Theme) {
603- theme.HasForeground = true
604- theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
605- theme.HasBackground = true
606- theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
607- })
608- transport := newBlockingThemeRestoreTransport()
609- ctx, cancel := context.WithCancel(context.Background())
610- resultCh := make(chan attachResult, 1)
611- ms := milestones{}
612- attempt := newReconnectAttachAttempt(term.term, transport, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentResume, SessionName: "main", Remote: true}, 44, themeState, nil, &ms)
613- go func() { resultCh <- attempt.run(ctx) }()
614-
615- select {
616- case <-transport.started:
617- case <-time.After(time.Second):
618- cancel()
619- _ = transport.Close()
620- t.Fatal("cached theme restoration did not start")
621- }
622- cancel()
623- select {
624- case result := <-resultCh:
625- require.True(t, result.welcomed)
626- require.NoError(t, result.err)
627- _ = transport.Close()
628- case <-time.After(100 * time.Millisecond):
629- _ = transport.Close()
630- <-resultCh
631- t.Fatal("cached theme send blocked attach cancellation")
632- }
633-}
634-
635-func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
636- t.Setenv("TERM", "xterm-256color")
637- t.Setenv("COLORTERM", "truecolor")
638- oldSleep := reconnectSleep
639- oldSleepWithResize := reconnectSleepWithResize
640- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
641- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
642- return true
643- }
644- defer func() {
645- reconnectSleep = oldSleep
646- reconnectSleepWithResize = oldSleepWithResize
647- }()
648-
649- term := newReconnectToastTerminalHarness(t)
650- defer term.closeInput()
651- go func() {
652- _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07"))
653- }()
654- tr1 := newReconnectThemeTransport(44, true)
655- tr2 := newReconnectThemeTransport(55, false)
656- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
657-
658- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
659- require.NoError(t, err)
660- require.True(t, tr2.observedTheme.HasForeground)
661- require.True(t, tr2.observedTheme.HasBackground)
662- require.True(t, tr2.observedTheme.TrueColor)
663- require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground)
664- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background)
665-}
666-
667542 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
668543 linkDead := errors.New("remote link dead")
669544 oldSleep := reconnectSleep
@@ -708,18 +583,19 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
708583 func TestRemoteReconnectToastLifecycle(t *testing.T) {
709584 tests := []struct {
710585 name string
711- configure func(t *testing.T, dialer *portsmocks.MockDialer)
586+ configure func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport
712587 sleep func(cancel context.CancelFunc) bool
713588 wantErr func(t *testing.T, err error)
714589 wantBlank bool
715590 }{
716591 {
717592 name: "clears on successful reconnect",
718- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
719- tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
720- tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
593+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
594+ tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
595+ tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
721596 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
722597 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
598+ return []*reconnectToastRecordingTransport{tr1, tr2}
723599 },
724600 sleep: func(context.CancelFunc) bool { return true },
725601 wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
@@ -727,9 +603,10 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
727603 },
728604 {
729605 name: "clears on cancellation",
730- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
731- tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
606+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
607+ tr := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
732608 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
609+ return []*reconnectToastRecordingTransport{tr}
733610 },
734611 sleep: func(cancel context.CancelFunc) bool {
735612 cancel()
@@ -740,11 +617,12 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
740617 },
741618 {
742619 name: "clears on final exit",
743- configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
744- tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
745- tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
620+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport {
621+ tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
622+ tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
746623 dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
747624 dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
625+ return []*reconnectToastRecordingTransport{tr1, tr2}
748626 },
749627 sleep: func(context.CancelFunc) bool { return true },
750628 wantErr: func(t *testing.T, err error) {
@@ -772,10 +650,13 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
772650 term := newReconnectToastTerminalHarness(t)
773651 defer term.closeInput()
774652 dialer := portsmocks.NewMockDialer(t)
775- tt.configure(t, dialer)
653+ transports := tt.configure(t, dialer)
776654
777655 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
778656 tt.wantErr(t, err)
657+ for _, transport := range transports {
658+ assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport)
659+ }
779660 out := term.out.String()
780661 require.Contains(t, out, reconnectToastMessage)
781662 if tt.wantBlank {
A internal/usecase/client/terminal_input_pump_test.go +498 -0
@@ -0,0 +1,498 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+type lifecycleReader struct {
19+ chunks chan []byte
20+ started chan struct{}
21+
22+ mu sync.Mutex
23+ active int
24+ max int
25+}
26+
27+func newLifecycleReader() *lifecycleReader {
28+ return &lifecycleReader{chunks: make(chan []byte), started: make(chan struct{}, 4)}
29+}
30+
31+func (r *lifecycleReader) Read(p []byte) (int, error) {
32+ r.mu.Lock()
33+ r.active++
34+ if r.active > r.max {
35+ r.max = r.active
36+ }
37+ r.mu.Unlock()
38+ r.started <- struct{}{}
39+ data, ok := <-r.chunks
40+ r.mu.Lock()
41+ r.active--
42+ r.mu.Unlock()
43+ if !ok {
44+ return 0, io.EOF
45+ }
46+ return copy(p, data), nil
47+}
48+
49+func (r *lifecycleReader) maxActive() int {
50+ r.mu.Lock()
51+ defer r.mu.Unlock()
52+ return r.max
53+}
54+
55+type lateStopReader struct {
56+ started chan struct{}
57+ release chan terminalReadResult
58+ reads atomic.Int32
59+}
60+
61+func newLateStopReader() *lateStopReader {
62+ return &lateStopReader{
63+ started: make(chan struct{}, 2),
64+ release: make(chan terminalReadResult, 1),
65+ }
66+}
67+
68+func (r *lateStopReader) Read(p []byte) (int, error) {
69+ r.reads.Add(1)
70+ r.started <- struct{}{}
71+ result := <-r.release
72+ return copy(p, result.data), result.err
73+}
74+
75+func requireSignal(t *testing.T, ch <-chan struct{}, message string) {
76+ t.Helper()
77+ select {
78+ case <-ch:
79+ case <-time.After(time.Second):
80+ t.Fatal(message)
81+ }
82+}
83+
84+func requireFrame(t *testing.T, ch <-chan ports.Frame, message string) ports.Frame {
85+ t.Helper()
86+ select {
87+ case frame := <-ch:
88+ return frame
89+ case <-time.After(time.Second):
90+ t.Fatal(message)
91+ return ports.Frame{}
92+ }
93+}
94+
95+func requireTimer(t *testing.T, ch <-chan *attachPaletteTimer, message string) *attachPaletteTimer {
96+ t.Helper()
97+ select {
98+ case timer := <-ch:
99+ return timer
100+ case <-time.After(time.Second):
101+ t.Fatal(message)
102+ return nil
103+ }
104+}
105+
106+func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) {
107+ for _, tc := range []struct {
108+ slot int
109+ want uint8
110+ ok bool
111+ }{
112+ {slot: -1},
113+ {slot: 0, want: 0, ok: true},
114+ {slot: 15, want: 15, ok: true},
115+ {slot: 16},
116+ {slot: 256},
117+ } {
118+ got, ok := paletteSlot(tc.slot)
119+ require.Equal(t, tc.ok, ok, "slot %d", tc.slot)
120+ if ok {
121+ require.Equal(t, tc.want, got, "slot %d", tc.slot)
122+ }
123+ }
124+}
125+
126+func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) {
127+ reader := newLateStopReader()
128+ input := newTerminalInputPump(reader)
129+ input.start()
130+ <-reader.started // The sole lifecycle read is blocked in the caller-owned reader.
131+
132+ input.stop()
133+ reader.release <- terminalReadResult{data: []byte("late")}
134+
135+ // The late read must be discarded rather than published. Both possible
136+ // outcomes are channel-synchronized: a correct pump exits; the historical
137+ // race published and immediately issued a second Read.
138+ select {
139+ case <-input.exited:
140+ case <-reader.started:
141+ require.Fail(t, "pump issued another Read after stop")
142+ }
143+ require.Equal(t, int32(1), reader.reads.Load())
144+
145+ consumer := input.claim()
146+ _, ok := input.take(context.Background(), consumer)
147+ require.False(t, ok, "late read must not be available after stop")
148+ input.revoke(consumer)
149+ input.stop() // stop is deliberately idempotent.
150+}
151+
152+func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) {
153+ reader := newLateStopReader()
154+ runner := &Runner{term: &attachPaletteTerminalHarness{
155+ in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Size),
156+ }}
157+
158+ first := runner.terminalInput()
159+ <-reader.started
160+ first.suspend()
161+ second := runner.terminalInput()
162+ require.Same(t, first, second, "a later Run must reuse a blocked caller-owned reader")
163+ require.Equal(t, int32(1), reader.reads.Load())
164+
165+ first.stop()
166+ reader.release <- terminalReadResult{}
167+ <-first.exited
168+}
169+
170+func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T) {
171+ input := newTerminalInputPump(nil)
172+ // Both cancellation and a terminal read are ready before the old scanner
173+ // tries to dequeue. It must surrender the read to its replacement.
174+ want := []byte("first\x00second\xff")
175+ input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1)
176+
177+ var activeGeneration atomic.Uint64
178+ cancelledCtx, cancelCancelled := context.WithCancel(context.Background())
179+ cancelCancelled()
180+ cancelledDone := make(chan struct{})
181+ go func() {
182+ (&stdinPump{
183+ ctx: cancelledCtx, cancel: cancelCancelled, input: input,
184+ out: make(chan ports.Frame, 1), clock: newAttachPaletteClock(),
185+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
186+ activeGeneration: &activeGeneration,
187+ }).run()
188+ close(cancelledDone)
189+ }()
190+ <-cancelledDone
191+
192+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
193+ defer cancelReplacement()
194+ out := make(chan ports.Frame, len(want))
195+ replacementDone := make(chan struct{})
196+ go func() {
197+ (&stdinPump{
198+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
199+ out: out, clock: newAttachPaletteClock(),
200+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
201+ activeGeneration: &activeGeneration,
202+ }).run()
203+ close(replacementDone)
204+ }()
205+
206+ var got []byte
207+ for len(got) < len(want) {
208+ frame := <-out
209+ input, err := ports.UnmarshalInput(frame.Payload)
210+ require.NoError(t, err)
211+ got = append(got, input.Data...)
212+ }
213+ require.Equal(t, want, got, "handoff must preserve every queued byte in order")
214+
215+ cancelReplacement()
216+ <-replacementDone
217+}
218+
219+func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
220+ input := newTerminalInputPump(nil)
221+ want := []byte("first\x00second\xff")
222+ input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1)
223+
224+ ctx, cancel := context.WithCancel(context.Background())
225+ consumer := input.claim()
226+ taken := make(chan struct{})
227+ release := make(chan struct{})
228+ done := make(chan struct{})
229+ go func() {
230+ (&stdinPump{
231+ ctx: ctx, cancel: cancel, input: input, consumer: consumer,
232+ out: make(chan ports.Frame, 1), clock: newAttachPaletteClock(),
233+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
234+ afterInputTake: func() {
235+ close(taken)
236+ <-release
237+ },
238+ }).run()
239+ close(done)
240+ }()
241+
242+ <-taken
243+ cancel()
244+ close(release)
245+ <-done
246+ input.revoke(consumer)
247+
248+ replacement := input.claim()
249+ got, ok := input.take(context.Background(), replacement)
250+ require.True(t, ok, "cancelled delivery must leave the read for the replacement scanner")
251+ require.Equal(t, want, got.data)
252+ input.ack(replacement)
253+ input.revoke(replacement)
254+}
255+
256+func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) {
257+ input := newTerminalInputPump(nil)
258+ input.enqueue(terminalReadResult{data: []byte("\x1b")}, 1)
259+ var activeGeneration atomic.Uint64
260+
261+ oldCtx, cancelOld := context.WithCancel(context.Background())
262+ oldClock := newAttachPaletteClock()
263+ oldDone := make(chan struct{})
264+ go func() {
265+ (&stdinPump{
266+ ctx: oldCtx, cancel: cancelOld, input: input, out: make(chan ports.Frame, 1),
267+ clock: oldClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
268+ activeGeneration: &activeGeneration,
269+ }).run()
270+ close(oldDone)
271+ }()
272+ // The ambiguity timer proves the ESC was scanned and its raw read was
273+ // committed; only the marker scanner's undecided suffix remains.
274+ <-oldClock.timers
275+ cancelOld()
276+ <-oldDone
277+
278+ newCtx, cancelNew := context.WithCancel(context.Background())
279+ defer cancelNew()
280+ newClock := newAttachPaletteClock()
281+ out := make(chan ports.Frame, 1)
282+ newDone := make(chan struct{})
283+ go func() {
284+ (&stdinPump{
285+ ctx: newCtx, cancel: cancelNew, input: input, out: out,
286+ clock: newClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
287+ activeGeneration: &activeGeneration,
288+ }).run()
289+ close(newDone)
290+ }()
291+ (<-newClock.timers).fire() // marker ambiguity deadline
292+ (<-newClock.timers).fire() // paste coalescer's lone-ESC deadline
293+ frame := <-out
294+ got, err := ports.UnmarshalInput(frame.Payload)
295+ require.NoError(t, err)
296+ require.Equal(t, []byte("\x1b"), got.Data)
297+
298+ cancelNew()
299+ <-newDone
300+}
301+
302+func TestTerminalInputPumpCancellationAfterInputBeforeOSCPreservesOnlyUndeliveredSuffix(t *testing.T) {
303+ input := newTerminalInputPump(nil)
304+ input.enqueue(terminalReadResult{data: []byte("x\x1b]10;rgb:ffff/0000/0000\x07y")}, 1)
305+ var activeGeneration atomic.Uint64
306+ ctx, cancel := context.WithCancel(context.Background())
307+ delivered := make(chan struct{})
308+ done := make(chan struct{})
309+ out := make(chan ports.Frame, 1)
310+ go func() {
311+ (&stdinPump{
312+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
313+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
314+ activeGeneration: &activeGeneration,
315+ afterInputDelivered: func() {
316+ cancel()
317+ close(delivered)
318+ },
319+ }).run()
320+ close(done)
321+ }()
322+ frame := requireFrame(t, out, "timed out waiting for delivered input prefix")
323+ got, err := ports.UnmarshalInput(frame.Payload)
324+ require.NoError(t, err)
325+ require.Equal(t, []byte("x"), got.Data)
326+ requireSignal(t, delivered, "timed out waiting for cancellation after input delivery")
327+ requireSignal(t, done, "timed out waiting for cancelled stdin pump")
328+
329+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
330+ replacementOut := make(chan ports.Frame, 1)
331+ replacementDone := make(chan struct{})
332+ go func() {
333+ (&stdinPump{
334+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
335+ out: replacementOut, clock: newAttachPaletteClock(),
336+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
337+ activeGeneration: &activeGeneration,
338+ }).run()
339+ close(replacementDone)
340+ }()
341+ frame = requireFrame(t, replacementOut, "timed out waiting for undelivered input suffix")
342+ got, err = ports.UnmarshalInput(frame.Payload)
343+ require.NoError(t, err)
344+ require.Equal(t, []byte("y"), got.Data, "replacement must receive only the undelivered ordinary suffix")
345+
346+ cancelReplacement()
347+ requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown")
348+}
349+
350+func TestTerminalInputPumpCancellationPreservesCoalescerHeldSuffix(t *testing.T) {
351+ input := newTerminalInputPump(nil)
352+ held := ports.BracketedPasteOpenMarker[:3]
353+ raw := append([]byte("x\x1b]10;rgb:ffff/0000/0000\x07"), held...)
354+ input.enqueue(terminalReadResult{data: raw}, 1)
355+ var activeGeneration atomic.Uint64
356+ ctx, cancel := context.WithCancel(context.Background())
357+ out := make(chan ports.Frame, 1)
358+ done := make(chan struct{})
359+ go func() {
360+ (&stdinPump{
361+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
362+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
363+ activeGeneration: &activeGeneration,
364+ afterInputDelivered: cancel,
365+ }).run()
366+ close(done)
367+ }()
368+ frame := requireFrame(t, out, "timed out waiting for delivered input prefix")
369+ got, err := ports.UnmarshalInput(frame.Payload)
370+ require.NoError(t, err)
371+ require.Equal(t, []byte("x"), got.Data)
372+ requireSignal(t, done, "timed out waiting for cancelled stdin pump")
373+
374+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
375+ replacementClock := newAttachPaletteClock()
376+ replacementOut := make(chan ports.Frame, 1)
377+ replacementDone := make(chan struct{})
378+ go func() {
379+ (&stdinPump{
380+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
381+ out: replacementOut, clock: replacementClock,
382+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
383+ activeGeneration: &activeGeneration,
384+ }).run()
385+ close(replacementDone)
386+ }()
387+ requireTimer(t, replacementClock.timers, "timed out waiting for paste-prefix ambiguity timer").fire()
388+ frame = requireFrame(t, replacementOut, "timed out waiting for coalescer-held suffix")
389+ got, err = ports.UnmarshalInput(frame.Payload)
390+ require.NoError(t, err)
391+ require.Equal(t, held, got.Data, "replacement must receive the coalescer-held suffix exactly once")
392+
393+ cancelReplacement()
394+ requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown")
395+}
396+
397+func TestTerminalInputPumpRejectsReadFromPriorActivation(t *testing.T) {
398+ reader := newLateStopReader()
399+ input := newTerminalInputPump(reader)
400+ input.start()
401+ requireSignal(t, reader.started, "timed out waiting for initial activation Read")
402+
403+ input.suspend()
404+ input.resume()
405+ reader.release <- terminalReadResult{data: []byte("stale")}
406+
407+ // The old read must be discarded after resume, then the reader may begin a
408+ // new Read for the current activation.
409+ requireSignal(t, reader.started, "timed out waiting for current activation Read")
410+ consumer := input.claim()
411+ _, ok := input.take(context.Background(), consumer)
412+ require.False(t, ok, "a Read authorized before suspend/resume must not reach the new activation")
413+ input.revoke(consumer)
414+
415+ input.stop()
416+ reader.release <- terminalReadResult{}
417+ requireSignal(t, input.exited, "timed out waiting for terminal input pump shutdown")
418+}
419+
420+func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) {
421+ input := newTerminalInputPump(nil)
422+ input.enqueue(terminalReadResult{data: []byte("x")}, 1)
423+ var activeGeneration atomic.Uint64
424+ ctx, cancel := context.WithCancel(context.Background())
425+ delivered := make(chan struct{})
426+ done := make(chan struct{})
427+ out := make(chan ports.Frame, 1)
428+ go func() {
429+ (&stdinPump{
430+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
431+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
432+ activeGeneration: &activeGeneration,
433+ afterInputDelivered: func() {
434+ cancel()
435+ close(delivered)
436+ },
437+ }).run()
438+ close(done)
439+ }()
440+ frame := <-out
441+ got, err := ports.UnmarshalInput(frame.Payload)
442+ require.NoError(t, err)
443+ require.Equal(t, []byte("x"), got.Data)
444+ <-delivered
445+ <-done
446+
447+ consumer := input.claim()
448+ _, ok := input.take(context.Background(), consumer)
449+ require.False(t, ok, "a callback already delivered to the sender must not replay after reconnect")
450+ input.revoke(consumer)
451+}
452+
453+func TestTerminalInputPumpReusesOneReaderAcrossCancelledAttempts(t *testing.T) {
454+ reader := newLifecycleReader()
455+ input := newTerminalInputPump(reader)
456+ input.start()
457+ t.Cleanup(input.stop)
458+ <-reader.started // The lifecycle pump owns the sole blocking Read.
459+
460+ var activeGeneration atomic.Uint64
461+ firstCtx, cancelFirst := context.WithCancel(context.Background())
462+ firstDone := make(chan struct{})
463+ go func() {
464+ (&stdinPump{
465+ ctx: firstCtx, cancel: cancelFirst, input: input,
466+ out: make(chan ports.Frame), clock: newAttachPaletteClock(),
467+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
468+ activeGeneration: &activeGeneration,
469+ }).run()
470+ close(firstDone)
471+ }()
472+ cancelFirst()
473+ <-firstDone
474+
475+ secondCtx, cancelSecond := context.WithCancel(context.Background())
476+ defer cancelSecond()
477+ out := make(chan ports.Frame, 1)
478+ secondDone := make(chan struct{})
479+ go func() {
480+ (&stdinPump{
481+ ctx: secondCtx, cancel: cancelSecond, input: input,
482+ out: out, clock: newAttachPaletteClock(),
483+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
484+ activeGeneration: &activeGeneration,
485+ }).run()
486+ close(secondDone)
487+ }()
488+
489+ reader.chunks <- []byte("x")
490+ frame := <-out
491+ got, err := ports.UnmarshalInput(frame.Payload)
492+ require.NoError(t, err)
493+ require.Equal(t, []byte("x"), got.Data)
494+ require.Equal(t, 1, reader.maxActive(), "cancelled attempts must not leave competing reads")
495+
496+ cancelSecond()
497+ <-secondDone
498+}
D internal/usecase/client/theme_state_test.go +0 -286
@@ -1,286 +0,0 @@
1-package client
2-
3-import (
4- "bytes"
5- "context"
6- "io"
7- "log/slog"
8- "testing"
9- "time"
10-
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/pkg/renderer"
15-)
16-
17-func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
18- state := &terminalThemeState{}
19- state.update(func(theme *ports.Theme) {
20- theme.HasForeground = true
21- theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
22- theme.HasBackground = true
23- theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
24- theme.SchemeKnown = true
25- theme.Light = true
26- theme.PaletteKnown = 1<<1 | 1<<14
27- theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9}
28- theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12}
29- })
30-
31- got := state.clearPalette()
32- require.True(t, got.HasForeground)
33- require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground)
34- require.True(t, got.HasBackground)
35- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background)
36- require.True(t, got.SchemeKnown)
37- require.True(t, got.Light)
38- require.Zero(t, got.PaletteKnown)
39- require.Equal(t, [16]renderer.RGB{}, got.Palette)
40-
41- _, reported := state.reportedTheme()
42- require.True(t, reported, "foreground/background remain usable after palette invalidation")
43-}
44-
45-type queryBoundaryReader struct {
46- first, second []byte
47- readSecond chan struct{}
48- allowSecond, close <-chan struct{}
49- reads int
50-}
51-
52-func (r *queryBoundaryReader) Read(p []byte) (int, error) {
53- switch r.reads {
54- case 0:
55- r.reads++
56- return copy(p, r.first), nil
57- case 1:
58- select {
59- case r.readSecond <- struct{}{}:
60- case <-r.close:
61- return 0, io.EOF
62- }
63- select {
64- case <-r.allowSecond:
65- r.reads++
66- return copy(p, r.second), nil
67- case <-r.close:
68- return 0, io.EOF
69- }
70- default:
71- <-r.close
72- return 0, io.EOF
73- }
74-}
75-
76-func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
77- state := &terminalThemeState{}
78- ctx, cancel := context.WithCancel(context.Background())
79- defer cancel()
80- out := make(chan ports.Frame, 3)
81- pump := stdinPump{
82- ctx: ctx,
83- cancel: cancel,
84- in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
85- out: out,
86- themeState: state,
87- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
88- }
89-
90- pump.run()
91-
92- first := <-out
93- require.Equal(t, ports.MsgTheme, first.Type)
94- theme, err := ports.UnmarshalTheme(first.Payload)
95- require.NoError(t, err)
96- require.True(t, theme.HasBackground)
97- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
98- require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
99- require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
100- require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
101-
102- second := <-out
103- require.Equal(t, ports.MsgDetach, second.Type)
104- select {
105- case extra := <-out:
106- t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
107- default:
108- }
109-}
110-
111-func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) {
112- ctx, cancel := context.WithCancel(context.Background())
113- defer cancel()
114- allowSecond := make(chan struct{})
115- closeReader := make(chan struct{})
116- reader := &queryBoundaryReader{
117- first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"),
118- second: []byte("\x1b]4;2;#445566\a"),
119- readSecond: make(chan struct{}, 1),
120- allowSecond: allowSecond,
121- close: closeReader,
122- }
123- queryStarted := make(chan struct{})
124- allowQuery := make(chan struct{})
125- out := make(chan ports.Frame, 3)
126- pump := stdinPump{
127- ctx: ctx,
128- cancel: cancel,
129- in: reader,
130- out: out,
131- themeState: &terminalThemeState{},
132- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
133- requestColors: func() (colorQueryRequest, bool) {
134- close(queryStarted)
135- select {
136- case <-allowQuery:
137- return colorQueryRequest{}, true
138- case <-ctx.Done():
139- return colorQueryRequest{}, false
140- }
141- },
142- }
143- pumpDone := make(chan struct{})
144- go func() {
145- pump.run()
146- close(pumpDone)
147- }()
148-
149- select {
150- case <-queryStarted:
151- case <-time.After(time.Second):
152- t.Fatal("palette re-query was not requested")
153- }
154- select {
155- case <-reader.readSecond:
156- t.Fatal("stdin pump started the next read before the query acknowledgement")
157- default:
158- }
159-
160- close(allowQuery)
161- select {
162- case <-reader.readSecond:
163- case <-time.After(time.Second):
164- t.Fatal("stdin pump did not resume after the query acknowledgement")
165- }
166-
167- cancel()
168- close(closeReader)
169- select {
170- case <-pumpDone:
171- case <-time.After(time.Second):
172- t.Fatal("stdin pump did not unwind after cancellation")
173- }
174-}
175-
176-type chunksReader struct {
177- chunks [][]byte
178- next int
179-}
180-
181-func (r *chunksReader) Read(p []byte) (int, error) {
182- if r.next == len(r.chunks) {
183- return 0, io.EOF
184- }
185- chunk := r.chunks[r.next]
186- r.next++
187- return copy(p, chunk), nil
188-}
189-
190-func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191- ctx, cancel := context.WithCancel(context.Background())
192- defer cancel()
193- requests := 0
194- pump := stdinPump{
195- ctx: ctx,
196- cancel: cancel,
197- in: &chunksReader{chunks: [][]byte{
198- []byte("\x1b[?997;2n"),
199- []byte("\x1b[?997;1n"),
200- }},
201- out: make(chan ports.Frame, 3),
202- themeState: &terminalThemeState{},
203- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
204- requestColors: func() (colorQueryRequest, bool) {
205- requests++
206- return colorQueryRequest{}, true
207- },
208- }
209-
210- pump.run()
211- require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives")
212-}
213-
214-func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) {
215- for _, status := range []byte{'0', '1', '2', '3', '4'} {
216- t.Run(string(status), func(t *testing.T) {
217- response := []byte("\x1b[?2031;" + string(status) + "$y")
218- var got []byte
219- boundaries := 0
220- drain := paletteDrain{}
221- drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) {
222- got = append(got, data...)
223- }, func() { boundaries++ })
224- drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) {
225- got = append(got, data...)
226- }, func() { boundaries++ })
227-
228- require.Equal(t, []byte("xy"), got)
229- require.Equal(t, 1, boundaries)
230- })
231- }
232-}
233-
234-func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) {
235- var got []byte
236- boundaries := 0
237- drain := paletteDrain{}
238- emit := func(data []byte) { got = append(got, data...) }
239- drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ })
240- drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ })
241- drain.flush(emit)
242-
243- require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got)
244- require.Zero(t, boundaries)
245-}
246-
247-func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {
248- ctx, cancel := context.WithCancel(context.Background())
249- closeReader := make(chan struct{})
250- reader := &queryBoundaryReader{
251- first: []byte("\x1b[?997;2n"),
252- close: closeReader,
253- }
254- requestStarted := make(chan struct{})
255- pump := stdinPump{
256- ctx: ctx,
257- cancel: cancel,
258- in: reader,
259- out: make(chan ports.Frame, 2),
260- themeState: &terminalThemeState{},
261- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
262- requestColors: func() (colorQueryRequest, bool) {
263- close(requestStarted)
264- <-ctx.Done()
265- return colorQueryRequest{}, false
266- },
267- }
268- pumpDone := make(chan struct{})
269- go func() {
270- pump.run()
271- close(pumpDone)
272- }()
273-
274- select {
275- case <-requestStarted:
276- case <-time.After(time.Second):
277- t.Fatal("palette re-query was not requested")
278- }
279- cancel()
280- select {
281- case <-pumpDone:
282- case <-time.After(time.Second):
283- t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement")
284- }
285- close(closeReader)
286-}
M internal/usecase/client/transport_observability_test.go +5 -3
@@ -37,7 +37,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
3737 defer in.unblock()
3838 term.EXPECT().In().Return(in).Maybe()
3939 term.EXPECT().Out().Return(&out).Maybe()
40- term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()
40+ term.EXPECT().Flush().Run(func() { closeOnce(flushed) }).Return(nil).Maybe()
4141 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
4242
4343 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
@@ -46,6 +46,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
4646 acked := make(chan struct{})
4747 transport := portsmocks.NewMockTransport(t)
4848 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
49+ transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
4950 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
5051 unblock := scriptRecv(transport,
5152 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
@@ -70,7 +71,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
7071 case <-time.After(time.Second):
7172 t.Fatal("Attach did not complete after observer release")
7273 }
73- require.Equal(t, "flush-before-observe", out.String())
74+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$pflush-before-observe", out.String())
7475 require.Equal(t, int32(1), restores.Load())
7576 }
7677
@@ -86,6 +87,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
8687 defer reporter.Close()
8788 transport := portsmocks.NewMockTransport(t)
8889 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
90+ transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
8991 unblock := scriptRecv(transport,
9092 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
9193 recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},
@@ -98,7 +100,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
98100 err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
99101 require.NoError(t, err)
100102 reporter.Flush()
101- require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
103+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$punchanged-by-observer", out.String(), "observer must not alter terminal bytes")
102104 require.Equal(t, int32(1), restores.Load())
103105 // Carriage spans belong only to concrete adapters. The client owns the
104106 // post-successful-flush terminal boundary, before its ACK is queued.
M internal/usecase/copy/mode.go +1 -1
@@ -421,7 +421,7 @@ func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
421421 func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
422422 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
423423 for i := range row {
424- row[i] = renderer.BlankCell()
424+ row[i] = renderer.Cell{Rune: ' ', Style: style}
425425 }
426426 text := " [SCROLL] "
427427 if m.selection.Enabled {
M internal/usecase/copy/mode_test.go +9 -0
@@ -256,3 +256,12 @@ func TestOSC52LimitsAndBase64(t *testing.T) {
256256 encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
257257 require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
258258 }
259+
260+func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
261+ m := modeFor([]string{"alpha"}, 5)
262+ status := renderer.Style{Foreground: 1, Background: 2}
263+ selection := renderer.Style{Foreground: 3, Background: 4}
264+ frame := m.Render(status, selection)
265+
266+ require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
267+}
M internal/usecase/daemon/client.go +31 -9
@@ -23,6 +23,15 @@ type transportSnapshot struct {
2323 incarnation uint64
2424 }
2525
26+// appliedTheme is the single immutable chrome snapshot consumed by rendering.
27+// Raw retains terminal defaults for panes; Resolved belongs exclusively to vev
28+// chrome. Generation changes whenever either terminal input or policy changes.
29+type appliedTheme struct {
30+ Raw themeui.Theme
31+ Resolved themeui.ResolvedTheme
32+ Generation uint64
33+}
34+
2635 type attachedClient struct {
2736 tr ports.Transport
2837 transportIncarnation uint64
@@ -48,8 +57,8 @@ type attachedClient struct {
4857 sess Guarded[*session]
4958 mouseScan mouse.Scanner
5059 themeMu sync.Mutex
51- theme themeui.Theme
5260 clientTheme themeui.Theme
61+ appliedTheme appliedTheme
5362 lastCursor cursorOut
5463 renderStages renderStageHooks // optional render and handoff observability hooks
5564 // previousSession is guarded independently. It is retained through temporary
@@ -137,10 +146,16 @@ func (ac *attachedClient) clearCaptureFrames() {
137146 ac.sendMu.Unlock()
138147 }
139148
140-func (ac *attachedClient) getTheme() themeui.Theme {
149+func (ac *attachedClient) getAppliedTheme() appliedTheme {
141150 ac.themeMu.Lock()
142151 defer ac.themeMu.Unlock()
143- return ac.theme
152+ applied := ac.appliedTheme
153+ if applied.Generation == 0 {
154+ // An unattached client has no terminal report yet. Reuse the static
155+ // neutral cache rather than resolving from a render path.
156+ applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles}
157+ }
158+ return applied
144159 }
145160
146161 func (ac *attachedClient) getClientTheme() themeui.Theme {
@@ -149,9 +164,17 @@ func (ac *attachedClient) getClientTheme() themeui.Theme {
149164 return ac.clientTheme
150165 }
151166
152-func (ac *attachedClient) setTheme(t themeui.Theme) {
167+// setThemeForTest publishes a complete applied snapshot for tests that do
168+// not exercise daemon configuration. Production theme changes use
169+// applyHostThemeLocked, which supplies the active policy.
170+func (ac *attachedClient) setThemeForTest(t themeui.Theme) {
171+ ac.setAppliedTheme(appliedTheme{Raw: t, Resolved: themeui.Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
172+}
173+
174+func (ac *attachedClient) setAppliedTheme(next appliedTheme) {
153175 ac.themeMu.Lock()
154- ac.theme = t
176+ next.Generation = ac.appliedTheme.Generation + 1
177+ ac.appliedTheme = next
155178 ac.themeMu.Unlock()
156179 }
157180
@@ -432,7 +455,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
432455 sess.mu.Unlock()
433456 d.touchMRU(sess)
434457 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
435- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
458+ d.applyHostTheme(sess, ac, themeui.Theme{}, true)
436459 return ac, old, cleanup
437460 }
438461
@@ -526,7 +549,7 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached
526549 // (and run its own attach-time reset), so this call must leave the tabs
527550 // alone rather than clobbering that client's freshly applied colors.
528551 func (d *Daemon) resetScreenDefaultColors(sess *session) {
529- d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
552+ d.applyHostTheme(sess, nil, themeui.Theme{}, true)
530553 }
531554
532555 // retireReplacedClient owns old-link retirement and obsolete render-worker
@@ -710,9 +733,8 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
710733 Light: msg.Light,
711734 }
712735 ac.setClientTheme(clientTheme)
713- t := d.effectiveTheme(clientTheme)
714736
715- if !d.applyHostTheme(sess, ac, t, false) {
737+ if !d.applyHostTheme(sess, ac, clientTheme, false) {
716738 return
717739 }
718740 d.invalidateRender(sess, ac, true, "client.go")
M internal/usecase/daemon/config.go +15 -5
@@ -21,10 +21,11 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
2121 type themeConfigSnapshot struct {
2222 mode domain.ThemeMode
2323 paletteOff bool
24+ accent domain.ThemeAccent
2425 }
2526
2627 func (d *Daemon) storeThemeConfig(cfg domain.Config) {
27- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette}
28+ snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
2829 d.themeConfig.Store(&snapshot)
2930 }
3031
@@ -259,8 +260,7 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
259260 return command.Command{}, false
260261 }
261262
262-func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
263- config := d.currentThemeConfig()
263+func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme {
264264 switch config.mode {
265265 case domain.ThemeDark:
266266 return theme.BuiltinDark
@@ -272,6 +272,16 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
272272 }
273273 }
274274
275+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
276+ return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
277+}
278+
279+func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
280+ config := d.currentThemeConfig()
281+ effective := effectiveThemeForConfig(raw, config)
282+ return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}
283+}
284+
275285 func (d *Daemon) reapplyThemeAllSessions() {
276286 d.mu.Lock()
277287 sessions := make([]*session, 0, len(d.sessions))
@@ -290,12 +300,12 @@ func (d *Daemon) reapplyThemeSession(sess *session) {
290300 ac := sess.client
291301 sess.mu.Unlock()
292302 if ac == nil {
293- d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)
303+ d.applyHostTheme(sess, nil, theme.Theme{}, true)
294304 return
295305 }
296306 ac.sendMu.Lock()
297307 defer ac.sendMu.Unlock()
298308 sess.themeMu.Lock()
299309 defer sess.themeMu.Unlock()
300- d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false)
310+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
301311 }
M internal/usecase/daemon/config_test.go +2 -2
@@ -72,7 +72,7 @@ func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
7272
7373 require.Equal(t, clientPalette, ac.getClientTheme().Palette)
7474 require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
75- require.True(t, ac.getTheme().UsePalette)
75+ require.True(t, ac.getAppliedTheme().Raw.UsePalette)
7676
7777 cfg := domain.Defaults()
7878 cfg.ThemePalette = false
@@ -80,7 +80,7 @@ func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
8080
8181 // ApplyConfig reuses the live client report and repaints without requiring
8282 // a reconnect; only palette inheritance changes.
83- reloaded := ac.getTheme()
83+ reloaded := ac.getAppliedTheme().Raw
8484 require.Equal(t, clientPalette, reloaded.Palette)
8585 require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
8686 require.False(t, reloaded.UsePalette)
M internal/usecase/daemon/copymode.go +2 -3
@@ -412,11 +412,10 @@ func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
412412 func isCopyEscapePrefix(data []byte) bool {
413413 return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
414414 }
415-func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
415+func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, styles themeui.Styles) (renderer.Frame, []renderer.Damage) {
416416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
417417 return frame, nil
418418 }
419- styles := themeui.NewStyles(bars.theme)
420419 copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
421420 bodyRows := max(copyFrame.Height-1, 0)
422421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
@@ -430,7 +429,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
430429 }
431430 statusY := frame.Height - 1
432431 for x := range frame.Row(statusY) {
433- frame.Row(statusY)[x] = renderer.BlankCell()
432+ frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}
434433 }
435434 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
436435 return frame, []renderer.Damage{renderer.FullRedraw()}
M internal/usecase/daemon/copymode_test.go +36 -3
@@ -115,7 +115,6 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
115115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118- bars := barState{status: sess.statusSegments(true)}
119118
120119 base := renderer.NewFrame(80, 25)
121120 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
@@ -133,7 +132,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133132 go func() {
134133 defer wg.Done()
135134 for range 200 {
136- _, _ = composeCopyClientFrame(mode, target, base, bars)
135+ _, _ = composeCopyClientFrame(mode, target, base, resolveStyles(nil))
137136 }
138137 }()
139138 wg.Wait()
@@ -160,7 +159,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
160159 }},
161160 bars: bars,
162161 }, composeCacheInput{}).frame
163- frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
162+ frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, resolveStyles(nil))
164163
165164 require.Equal(t, 80, frame.Width)
166165 require.Equal(t, 25, frame.Height)
@@ -881,3 +880,37 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
881880 want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
882881 require.Equal(t, want, string(msg.Data))
883882 }
883+
884+func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
885+ for _, tt := range []struct {
886+ name string
887+ selectMode bool
888+ want string
889+ }{
890+ {name: "scroll", want: "[SCROLL]"},
891+ {name: "selection", selectMode: true, want: "[SELECT]"},
892+ } {
893+ t.Run(tt.name, func(t *testing.T) {
894+ base := renderer.NewFrame(20, 8)
895+ pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
896+ pane.screen.Write([]byte("copy"))
897+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators))
898+ if tt.selectMode {
899+ mode.ToggleLineSelection()
900+ }
901+ styles := resolveStyles(nil)
902+ styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3}
903+ styles.CopyStatus = renderer.Style{Foreground: 5, Background: 4}
904+
905+ frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles)
906+ status := frame.Row(frame.Height - 1)
907+ for x := range 12 {
908+ require.Truef(t, status[x].Style.Equal(styles.CopyStatus), "status content cell %d must use CopyStatus", x)
909+ }
910+ for x := 12; x < len(status); x++ {
911+ require.Truef(t, status[x].Style.Equal(styles.SurfaceBar), "surrounding status cell %d must use SurfaceBar", x)
912+ }
913+ require.Contains(t, rowText(status), tt.want)
914+ })
915+ }
916+}
M internal/usecase/daemon/floating_render.go +18 -10
@@ -86,14 +86,16 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
8686 }
8787
8888 type floatingComposeInput struct {
89- baseFrame renderer.Frame
90- baseDamage []renderer.Damage
91- floating capturedFloatingRenderState
92- content domain.Rect
93- layout capturedTabLayout
94- theme themeui.Theme
95- cache composeCacheInput
96- full bool
89+ baseFrame renderer.Frame
90+ baseDamage []renderer.Damage
91+ floating capturedFloatingRenderState
92+ content domain.Rect
93+ layout capturedTabLayout
94+ theme themeui.Theme
95+ borderMuted renderer.Style
96+ borderActive renderer.Style
97+ cache composeCacheInput
98+ full bool
9799 }
98100
99101 func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
@@ -103,6 +105,8 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
103105 content := input.content
104106 layoutSnap := input.layout
105107 theme := input.theme
108+ borderMuted := input.borderMuted
109+ borderActive := input.borderActive
106110 cache := input.cache
107111 full := input.full
108112
@@ -115,9 +119,13 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
115119 for _, d := range floating.pane.damage {
116120 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
117121 }
118- popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
122+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused
119123 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
120- drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border)
124+ border := borderMuted
125+ if floating.focused {
126+ border = borderActive
127+ }
128+ drawFloatingBorder(frame, geometry.Bounds, floating.title, border)
121129 if full || popupChanged {
122130 return frame, []renderer.Damage{renderer.FullRedraw()}
123131 }
M internal/usecase/daemon/floating_render_test.go +157 -0
@@ -10,6 +10,7 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/usecase/layout"
1314 themeui "github.com/bnema/vev/internal/usecase/theme"
1415 "github.com/bnema/vev/pkg/renderer"
1516 )
@@ -177,6 +178,77 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
177178 require.Equal(t, 1, damage[0].Height)
178179 }
179180
181+func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
182+ base := renderer.NewFrame(20, 9)
183+ content := domain.Rect{Y: 1, Width: 20, Height: 7}
184+ geometry := floatingGeometry{
185+ Bounds: domain.Rect{X: 3, Y: 1, Width: 8, Height: 5},
186+ Inner: domain.Rect{X: 4, Y: 2, Width: 6, Height: 3},
187+ }
188+ active := renderer.Style{Foreground: 2, Background: 3}
189+ muted := renderer.Style{Foreground: 4, Background: 5}
190+ pane := renderer.NewFrame(6, 3)
191+ for y := range pane.Height {
192+ for x := range pane.Width {
193+ pane.Set(x, y, renderer.Cell{Rune: rune('a' + y*pane.Width + x), Style: renderer.Style{Foreground: x + y*10, Background: x + y*10 + 1}})
194+ }
195+ }
196+
197+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
198+ baseFrame: base,
199+ floating: capturedFloatingRenderState{
200+ visible: true, focused: true, pane: capturedPaneRenderState{frame: pane}, geometry: geometry, title: "float", generation: 1,
201+ },
202+ content: content,
203+ borderActive: active,
204+ borderMuted: muted,
205+ full: true,
206+ })
207+ bounds := geometry.translate(content.X, content.Y)
208+ require.Equal(t, active, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style, "focused floating border must use BorderActive")
209+ require.NotEqual(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
210+ for y := range bounds.Inner.Height {
211+ for x := range bounds.Inner.Width {
212+ require.Equal(t, pane.At(x, y), frame.At(bounds.Inner.X+x, bounds.Inner.Y+y), "pane cell (%d,%d) must remain byte-identical", x, y)
213+ }
214+ }
215+}
216+
217+func TestComposeCapturedFloatingFrameUsesMutedBorderWhenUnfocused(t *testing.T) {
218+ base := renderer.NewFrame(12, 7)
219+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
220+ geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
221+ active, muted := renderer.Style{Foreground: 2}, renderer.Style{Foreground: 4}
222+
223+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
224+ baseFrame: base,
225+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, title: "float", generation: 1},
226+ content: content,
227+ borderActive: active,
228+ borderMuted: muted,
229+ full: true,
230+ })
231+ bounds := geometry.translate(content.X, content.Y)
232+ require.Equal(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style)
233+}
234+
235+func TestComposeCapturedFloatingFrameFocusChangeInvalidatesCache(t *testing.T) {
236+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
237+ geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}}
238+ frame, damage := composeCapturedFloatingFrame(floatingComposeInput{
239+ baseFrame: renderer.NewFrame(12, 7),
240+ floating: capturedFloatingRenderState{
241+ visible: true, focused: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, generation: 1,
242+ },
243+ content: content,
244+ cache: composeCacheInput{
245+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y),
246+ },
247+ })
248+ require.NotNil(t, frame)
249+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage, "a focused border role change must redraw the popup")
250+}
251+
180252 func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
181253 initial := floatingGeometry{
182254 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
@@ -356,6 +428,91 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi
356428 require.LessOrEqual(t, allocs, float64(2))
357429 }
358430
431+var (
432+ benchmarkComposeSink composedRenderFrame
433+ benchmarkOutputSink []byte
434+)
435+
436+// benchmarkCapturedRenderState is a stable, post-capture production input: one
437+// 80x24 pane, both chrome bars, and no pending pane damage. The primed cache
438+// below makes each benchmark operation exercise the cached compose path.
439+func benchmarkCapturedRenderState() capturedRenderState {
440+ pane := renderer.NewFrame(80, 24)
441+ for y := range pane.Height {
442+ for x := range pane.Width {
443+ pane.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26)})
444+ }
445+ }
446+ terminalTheme := themeui.Theme{
447+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
448+ Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
449+ Palette: [16]renderer.RGB{
450+ 2: {R: 0x7d, G: 0xb5, B: 0xb5},
451+ 4: {R: 0x6c, G: 0x9b, B: 0xd9},
452+ 10: {R: 0x7d, G: 0xb5, B: 0xb5},
453+ 12: {R: 0x6c, G: 0x9b, B: 0xd9},
454+ 14: {R: 0x7d, G: 0xb5, B: 0xb5},
455+ },
456+ PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
457+ HasFG: true,
458+ HasBG: true,
459+ TrueColor: true,
460+ Known: true,
461+ SchemeKnown: true,
462+ UsePalette: true,
463+ }
464+ placement := layout.Placement{ID: "benchmark-pane", Content: domain.Rect{Width: 80, Height: 24}}
465+ return capturedRenderState{
466+ layout: capturedTabLayout{
467+ area: domain.Rect{Width: 80, Height: 24},
468+ focus: placement.ID,
469+ placements: []layout.Placement{placement},
470+ fingerprint: "benchmark-one-pane",
471+ valid: true,
472+ },
473+ panes: []capturedPaneRenderState{{id: placement.ID, frame: pane, placement: placement, focused: true}},
474+ bars: barState{
475+ status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}},
476+ topRight: "vev",
477+ bottomRight: "main",
478+ mru: []recentSession{{name: "work"}, {name: "logs"}},
479+ theme: terminalTheme,
480+ },
481+ theme: terminalTheme,
482+ }
483+}
484+
485+func benchmarkComposeCacheInput(state capturedRenderState) composeCacheInput {
486+ priming := state
487+ priming.reset = true
488+ priming.panes = append([]capturedPaneRenderState(nil), state.panes...)
489+ priming.panes[0].damage = []renderer.Damage{renderer.FullRedraw()}
490+ return composeFrame(priming, composeCacheInput{}).cache
491+}
492+
493+func BenchmarkComposeCapturedFrame(b *testing.B) {
494+ state := benchmarkCapturedRenderState()
495+ cache := benchmarkComposeCacheInput(state)
496+ scratch := composeCacheInput{}
497+ draw := renderer.New(renderer.Capabilities{})
498+ var totalBytes int64
499+
500+ b.ReportAllocs()
501+ b.ResetTimer()
502+ for b.Loop() {
503+ composed := composeFrame(state, cache, scratch)
504+ scratch = composed.cache
505+ draw.Reset()
506+ out, err := draw.Draw(composed.frame, composed.damage)
507+ if err != nil {
508+ b.Fatal(err)
509+ }
510+ totalBytes += int64(len(out))
511+ benchmarkComposeSink, benchmarkOutputSink = composed, out
512+ }
513+ b.ReportMetric(float64(totalBytes)/float64(b.N), "output-bytes/op")
514+}
515+
359516 func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
360517 base := renderer.NewFrame(80, 24)
361518 content := domain.Rect{Y: 1, Width: 80, Height: 22}
M internal/usecase/daemon/modal_compose.go +3 -3
@@ -7,9 +7,9 @@ import (
77 "github.com/bnema/vev/pkg/renderer"
88 )
99
10-func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11- inner := modal.Composite(base, styles.Border)
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
10+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11+ inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase)
12+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})
1313 for y := range min(inner.Height, modalFrame.Height) {
1414 for x := range min(inner.Width, modalFrame.Width) {
1515 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
M internal/usecase/daemon/palette.go +2 -2
@@ -500,7 +500,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
500500 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
501501 styleSet := resolveStyles(styles)
502502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
503- return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
504- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance})
503+ return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription}, Guidance: guidance})
505505 })
506506 }
M internal/usecase/daemon/palette_test.go +68 -0
@@ -13,10 +13,78 @@ import (
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1414 "github.com/bnema/vev/internal/usecase/command"
1515 "github.com/bnema/vev/internal/usecase/palette"
16+ themeui "github.com/bnema/vev/internal/usecase/theme"
1617 "github.com/bnema/vev/internal/usecase/ui"
1718 "github.com/bnema/vev/pkg/renderer"
1819 )
1920
21+func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
22+ paletteColors := [16]renderer.RGB{}
23+ paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
24+ paletteColors[10] = paletteColors[2]
25+ accentTheme := themeui.Theme{
26+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
27+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
28+ Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
29+ }
30+ indexedTheme := accentTheme
31+ indexedTheme.TrueColor = false
32+ neutralTheme := accentTheme
33+ neutralTheme.PaletteKnown = 0
34+ neutralTheme.SchemeKnown = false
35+
36+ defaults := domain.Defaults()
37+ paletteOff := defaults
38+ paletteOff.ThemePalette = false
39+ forcedDark := defaults
40+ forcedDark.Theme = domain.ThemeDark
41+ forcedLight := defaults
42+ forcedLight.Theme = domain.ThemeLight
43+ tests := []struct {
44+ name string
45+ raw themeui.Theme
46+ config domain.Config
47+ indexed bool
48+ }{
49+ {name: "truecolor accent", raw: accentTheme, config: defaults},
50+ {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
51+ {name: "palette off", raw: accentTheme, config: paletteOff},
52+ {name: "forced dark", raw: accentTheme, config: forcedDark},
53+ {name: "forced light", raw: accentTheme, config: forcedLight},
54+ {name: "neutral fallback", raw: neutralTheme, config: defaults},
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ d := newTestDaemon(t, nil, stubClock{})
60+ d.ApplyConfig(tt.config)
61+ applied := d.resolveAppliedTheme(tt.raw)
62+ model := palette.New(palette.CommandResults([]command.Command{
63+ {Code: "ONE", Name: "One", Desc: "first description"},
64+ {Code: "TWO", Name: "Two", Desc: "second description"},
65+ }))
66+ model.Down()
67+ state := capturedRenderState{
68+ theme: applied.Raw,
69+ styles: applied.Resolved.Styles,
70+ layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
71+ }
72+ snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model}
73+
74+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
75+
76+ description := state.overlays.palette.inner.At(4, 1).Style
77+ require.Equal(t, state.styles.SurfaceInactive.Background, description.Background)
78+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
79+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
80+ if tt.indexed {
81+ require.Equal(t, 2, description.Foreground)
82+ require.False(t, description.HasBackgroundRGB)
83+ }
84+ })
85+ }
86+}
87+
2088 func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
2189 p1, release1 := newBlockingPTY(t)
2290 p2, release2 := newBlockingPTY(t)
M internal/usecase/daemon/picker_test.go +62 -13
@@ -642,35 +642,84 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
642642 require.True(t, subscribed)
643643 }
644644
645-func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) {
646- for _, tt := range []struct {
647- name string
648- theme themeui.Theme
645+func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) {
646+ palette := [16]renderer.RGB{}
647+ palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
648+ palette[10] = palette[2]
649+ accentTheme := themeui.Theme{
650+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
651+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
652+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
653+ }
654+ indexedTheme := accentTheme
655+ indexedTheme.TrueColor = false
656+ neutralTheme := accentTheme
657+ neutralTheme.PaletteKnown = 0
658+ neutralTheme.SchemeKnown = false
659+
660+ defaults := domain.Defaults()
661+ paletteOff := defaults
662+ paletteOff.ThemePalette = false
663+ forcedDark := defaults
664+ forcedDark.Theme = domain.ThemeDark
665+ forcedLight := defaults
666+ forcedLight.Theme = domain.ThemeLight
667+ tests := []struct {
668+ name string
669+ raw themeui.Theme
670+ config domain.Config
671+ indexed bool
649672 }{
650- {name: "default terminal", theme: themeui.Theme{}},
651- {name: "truecolor terminal", theme: themeui.BuiltinDark},
652- } {
673+ {name: "truecolor accent", raw: accentTheme, config: defaults},
674+ {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
675+ {name: "palette off", raw: accentTheme, config: paletteOff},
676+ {name: "forced dark", raw: accentTheme, config: forcedDark},
677+ {name: "forced light", raw: accentTheme, config: forcedLight},
678+ {name: "neutral fallback", raw: neutralTheme, config: defaults},
679+ }
680+
681+ for _, tt := range tests {
653682 t.Run(tt.name, func(t *testing.T) {
683+ d := newTestDaemon(t, nil, stubClock{})
684+ d.ApplyConfig(tt.config)
685+ applied := d.resolveAppliedTheme(tt.raw)
654686 state := capturedRenderState{
655- theme: tt.theme,
687+ theme: applied.Raw,
688+ styles: applied.Resolved.Styles,
656689 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
657690 }
658691 snap := &overlayRenderSnapshot{
659692 pickerActive: true,
660- pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0),
693+ pickerModel: picker.New([]picker.SessionView{
694+ {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
695+ {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
696+ }, "selected", 0),
661697 }
662698
663699 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
664700
701+ require.Equal(t, state.styles.SurfaceInactive.Background, state.styles.PickerDescription.Background)
702+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, state.styles.PickerDescription.HasBackgroundRGB)
703+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, state.styles.PickerDescription.BackgroundRGB)
704+ require.Equal(t, state.styles.SurfaceBar.Background, state.styles.PickerSeparator.Background)
705+ require.Equal(t, state.styles.SurfaceBar.HasBackgroundRGB, state.styles.PickerSeparator.HasBackgroundRGB)
706+ require.Equal(t, state.styles.SurfaceBar.BackgroundRGB, state.styles.PickerSeparator.BackgroundRGB)
707+ if tt.indexed {
708+ require.Equal(t, 2, state.styles.PickerDescription.Foreground)
709+ require.Equal(t, 2, state.styles.PickerSeparator.Foreground)
710+ require.False(t, state.styles.PickerDescription.HasBackgroundRGB)
711+ require.False(t, state.styles.PickerSeparator.HasBackgroundRGB)
712+ }
713+
665714 inner := state.overlays.picker.inner
666715 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
667716 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
668- want := themeui.MutedTextStyle(tt.theme)
717+ require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text retains the inactive row surface")
669718 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
670719 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
671720 cell := inner.At(x, y)
672721 require.Equal(t, '│', cell.Rune)
673- require.True(t, cell.Style.Equal(want))
722+ require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator retains the bar surface")
674723 }
675724 }
676725 })
@@ -679,7 +728,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T
679728
680729 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
681730 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
682- state := capturedRenderState{theme: themeui.BuiltinDark}
731+ state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
683732 cases := []struct {
684733 name string
685734 size domain.Size
@@ -725,7 +774,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
725774 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
726775 cell := inner.At(x, y)
727776 require.Equal(t, tt.wantSep, cell.Rune)
728- require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme)))
777+ require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator must use the cached PickerSeparator role")
729778 }
730779 }
731780 })
M internal/usecase/daemon/render.go +13 -8
@@ -295,7 +295,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
295295 // Contextual ranks are completely captured under paletteMu. Compose them
296296 // without reading the live MRU, whose order may have changed mid-interaction.
297297 bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
298- bars.theme = ac.getTheme()
298+ applied := ac.getAppliedTheme()
299+ bars.theme = applied.Raw
299300 attentionVisible := pulseVisible(bars.attentionFrame)
300301 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
301302
@@ -326,12 +327,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
326327 }
327328 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
328329 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
329- bars: bars,
330- overlays: capturedOverlays,
331- preview: preview,
332- floatingCfg: floatingCfg,
333- reset: reset,
334- lease: lease,
330+ bars: bars,
331+ overlays: capturedOverlays,
332+ preview: preview,
333+ floatingCfg: floatingCfg,
334+ styles: applied.Resolved.Styles,
335+ styleGeneration: applied.Generation,
336+ reset: reset,
337+ lease: lease,
335338 })
336339 endCapture(0, ok)
337340 if !ok {
@@ -357,11 +360,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
357360 d.emitFrame(sess, ac, state, composed, &marks)
358361 }
359362
363+var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
364+
360365 func resolveStyles(styles []themeui.Styles) themeui.Styles {
361366 if len(styles) > 0 {
362367 return styles[0]
363368 }
364- return themeui.NewStyles(themeui.Theme{})
369+ return fallbackChromeStyles
365370 }
366371
367372 type barCache struct {
M internal/usecase/daemon/render_capture.go +18 -9
@@ -43,6 +43,8 @@ type capturedRenderState struct {
4343 floating capturedFloatingRenderState
4444 bars barState
4545 theme themeui.Theme
46+ styles themeui.Styles
47+ styleGeneration uint64
4648 overlays capturedOverlayRenderState
4749 preview picker.Preview
4850 cursor capturedCursorInputs
@@ -74,6 +76,7 @@ type capturedPaneRenderState struct {
7476
7577 type capturedFloatingRenderState struct {
7678 visible bool
79+ focused bool
7780 pane capturedPaneRenderState
7881 geometry floatingGeometry
7982 title string
@@ -91,8 +94,9 @@ type capturedOverlayRenderState struct {
9194 }
9295
9396 type capturedModal struct {
94- modal ui.Modal
95- inner renderer.Frame
97+ modal ui.Modal
98+ inner renderer.Frame
99+ focused bool
96100 }
97101
98102 func (o capturedOverlayRenderState) active() bool {
@@ -166,12 +170,14 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
166170 }
167171
168172 type primaryCaptureRequest struct {
169- bars barState
170- overlays capturedOverlayRenderState
171- preview picker.Preview
172- floatingCfg domain.FloatingConfig
173- reset bool
174- lease *attachmentLease
173+ bars barState
174+ overlays capturedOverlayRenderState
175+ preview picker.Preview
176+ floatingCfg domain.FloatingConfig
177+ styles themeui.Styles
178+ styleGeneration uint64
179+ reset bool
180+ lease *attachmentLease
175181 }
176182
177183 // capturePrimaryRenderState is the ownership boundary for a primary render
@@ -227,6 +233,7 @@ func capturePrimaryRenderState(
227233 state := &scratch.state
228234 *state = capturedRenderState{
229235 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
236+ styles: request.styles, styleGeneration: request.styleGeneration,
230237 overlays: overlays, preview: preview,
231238 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
232239 floatingGeneration: tb.floating.generation,
@@ -279,7 +286,9 @@ func capturePrimaryRenderState(
279286 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
280287 }
281288 ac.captureFrames[p] = captured
282- state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
289+ // A visible floating pane is the terminal input target, so its structural
290+ // border carries the focused semantic role independently of its content.
291+ state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
283292 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
284293 p.mu.Unlock()
285294 }
M internal/usecase/daemon/render_pipeline.go +48 -26
@@ -9,8 +9,10 @@ import (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 "github.com/bnema/vev/internal/usecase/palette"
1111 "github.com/bnema/vev/internal/usecase/picker"
12+ "github.com/bnema/vev/internal/usecase/prompt"
1213 themeui "github.com/bnema/vev/internal/usecase/theme"
1314 "github.com/bnema/vev/internal/usecase/ui"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
1416 "github.com/bnema/vev/pkg/renderer"
1517 )
1618
@@ -36,11 +38,13 @@ type composeCacheInput struct {
3638 titleGenerations map[layout.PaneID]uint64
3739 damage []renderer.Damage
3840 floatingVisible bool
41+ floatingFocused bool
3942 floatingGeneration uint64
4043 floatingGeometry floatingGeometry
4144 floatingTitleGeneration uint64
4245 bars barCache
4346 theme themeui.Theme
47+ styleGeneration uint64
4448 }
4549
4650 type composedRenderFrame struct {
@@ -77,17 +81,24 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7781 copy(frame.Row(y), in.frame.Row(y))
7882 }
7983 }
80- styles := themeui.NewStyles(state.theme)
84+ styles := state.styles
85+ if styles == (themeui.Styles{}) {
86+ // Direct/test-only composition has no applied snapshot; production
87+ // capture always supplies immutable styles.
88+ styles = fallbackChromeStyles
89+ }
90+ state.styles = styles
8191 defaultDimmer := themeui.NewDimmer(state.theme)
92+ neutralBorder := styles.NeutralBorder
8293 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
8394 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
8495 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
8596 content := domain.Rect{Y: 1, Width: width, Height: rows}
8697 if state.layout.valid && state.layout.root != nil {
87- drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border))
98+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder))
8899 }
89100
90- full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
101+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
91102 titles := scratch.titleGenerations
92103 if titles == nil {
93104 titles = make(map[layout.PaneID]uint64, len(state.panes))
@@ -97,7 +108,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
97108 for _, pane := range state.panes {
98109 pl := offsetPlacement(pane.placement, 0, 1)
99110 if pl.TitleBar.Height > 0 {
100- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
111+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
101112 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
102113 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
103114 }
@@ -119,14 +130,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
119130 if state.floating.visible {
120131 var floatingDamage []renderer.Damage
121132 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
122- baseFrame: baseFrame,
123- baseDamage: damage,
124- floating: state.floating,
125- content: content,
126- layout: state.layout,
127- theme: state.theme,
128- cache: in,
129- full: full || state.overlays.active(),
133+ baseFrame: baseFrame,
134+ baseDamage: damage,
135+ floating: state.floating,
136+ content: content,
137+ layout: state.layout,
138+ theme: state.theme,
139+ borderMuted: styles.BorderMuted,
140+ borderActive: styles.BorderActive,
141+ cache: in,
142+ full: full || state.overlays.active(),
130143 })
131144 damage = floatingDamage
132145 }
@@ -152,17 +165,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
152165 cursorInputs := state.cursor
153166 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
154167 cursor := desiredCapturedCursor(cursorInputs)
155- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
168+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
156169 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
157170 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
158171 }
159172
160-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) {
161- style := styles.Border
162- if focused {
163- style = styles.StatusBar
164- } else {
165- style = dimmer.Dim(style)
173+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
174+ style := styles.StatusBar
175+ if !focused {
176+ style = dimmer.Dim(neutralBorder)
166177 }
167178 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
168179 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -191,29 +202,36 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
191202 if snap.copyPane != nil {
192203 o.copyPaneID = snap.copyPane.id
193204 }
194- styles := themeui.NewStyles(state.theme)
205+ styles := state.styles
206+ if styles == (themeui.Styles{}) {
207+ styles = fallbackChromeStyles
208+ }
195209 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
196210 if snap.copySearchModel != nil {
197211 o.copySearch.modal = copySearchModal
198- o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection)
212+ o.copySearch.focused = true
213+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
199214 }
200215 if snap.pickerActive && snap.pickerModel != nil {
201216 o.picker.modal = pickerModal
202- renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator}
217+ o.picker.focused = true
218+ renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator}
203219 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
204220 }
205221 if snap.paletteActive && snap.paletteModel != nil {
206222 o.palette.modal = paletteModalFor(size, paletteCfg)
223+ o.palette.focused = true
207224 guidance := ""
208225 if snap.paletteHints != nil {
209226 guidance = snap.paletteHints.Feedback
210227 }
211228 o.paletteGuidance = snap.paletteFeedback
212- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
229+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription}, Guidance: guidance, Feedback: snap.paletteFeedback})
213230 }
214231 if snap.promptActive && snap.promptModel != nil {
215232 o.prompt.modal = promptModalFor(snap.promptModel.Title())
216- o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent)
233+ o.prompt.focused = true
234+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
217235 }
218236 state.cursor.hiddenByOverlay = o.active()
219237 }
@@ -238,7 +256,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
238256 }
239257 }
240258 }
241- frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars)
259+ frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles)
242260 }
243261 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
244262 if o.paletteActive && !state.floating.visible {
@@ -248,7 +266,11 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
248266 if modal.inner.Width == 0 && modal.inner.Height == 0 {
249267 continue
250268 }
251- inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border)
269+ border := state.styles.BorderMuted
270+ if modal.focused {
271+ border = state.styles.BorderActive
272+ }
273+ inner := modal.modal.Composite(frame, border, state.styles.PickerBase)
252274 for y := range min(inner.Height, modal.inner.Height) {
253275 copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
254276 }
M internal/usecase/daemon/render_pipeline_cache_test.go +29 -1
@@ -125,6 +125,30 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes
125125 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
126126 }
127127
128+func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
129+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
130+ neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}
131+ expected := themeui.NewDimmer(theme).Dim(neutralBorder)
132+
133+ t.Run("divider", func(t *testing.T) {
134+ state := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
135+ state.styles.NeutralBorder = neutralBorder
136+
137+ out := composeFrame(state, composeCacheInput{})
138+ require.Equal(t, expected, out.frame.At(20, 1).Style)
139+ })
140+
141+ t.Run("unfocused title", func(t *testing.T) {
142+ state := cachedStackTitleState("collapsed", 1, true)
143+ state.theme = theme
144+ state.styles = themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
145+ state.styles.NeutralBorder = neutralBorder
146+
147+ out := composeFrame(state, composeCacheInput{})
148+ require.Equal(t, expected, out.frame.At(0, 1).Style)
149+ })
150+}
151+
128152 func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
129153 state := cachedStackTitleState("collapsed", 1, true)
130154 state.theme = themeui.Theme{
@@ -135,6 +159,7 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
135159 Foreground: renderer.RGB{R: 220, G: 210, B: 200},
136160 Background: renderer.RGB{R: 20, G: 30, B: 40},
137161 }
162+ state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
138163 before := cloneCapturedStackState(state)
139164
140165 out := composeFrame(state, composeCacheInput{})
@@ -208,6 +233,7 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.
208233 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
209234 panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
210235 theme: theme,
236+ styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
211237 }
212238 }
213239
@@ -221,6 +247,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
221247 reset: reset,
222248 layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
223249 panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
250+ styles: resolveStyles(nil),
224251 }
225252 }
226253
@@ -241,7 +268,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
241268 func cacheBarState(bottom string, reset bool) capturedRenderState {
242269 pane := cachePaneFrame(6, 1, 'P')
243270 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
244- return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}}
271+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}, styles: resolveStyles(nil)}
245272 }
246273
247274 func cachePaneFrame(width, height int, r rune) renderer.Frame {
@@ -263,6 +290,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
263290 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
264291 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
265292 bars: barState{topRight: title, bottomRight: title},
293+ styles: resolveStyles(nil),
266294 }
267295 }
268296
M internal/usecase/daemon/render_pipeline_test.go +29 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/ports"
1515 "github.com/bnema/vev/internal/testutil/replaytest"
1616 "github.com/bnema/vev/internal/usecase/layout"
17+ themeui "github.com/bnema/vev/internal/usecase/theme"
1718 "github.com/bnema/vev/internal/usecase/ui"
1819 "github.com/bnema/vev/pkg/renderer"
1920 "github.com/bnema/vev/pkg/vt"
@@ -223,6 +224,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
223224 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
224225 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
225226 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
227+ styles: resolveStyles(nil),
226228 }
227229 composed := composeFrame(state, composeCacheInput{})
228230 require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
@@ -290,3 +292,30 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
290292 })
291293 }
292294 }
295+
296+func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
297+ muted := renderer.Style{Foreground: 1, Background: 2}
298+ active := renderer.Style{Foreground: 3, Background: 4}
299+ interior := renderer.Style{Foreground: 5, Background: 6}
300+ modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
301+
302+ for _, tt := range []struct {
303+ name string
304+ focused bool
305+ border renderer.Style
306+ }{
307+ {name: "unfocused", focused: false, border: muted},
308+ {name: "focused", focused: true, border: active},
309+ } {
310+ t.Run(tt.name, func(t *testing.T) {
311+ state := capturedRenderState{
312+ styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313+ overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314+ }
315+ frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})
316+ bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})
317+ require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318+ require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319+ })
320+ }
321+}
M internal/usecase/daemon/render_test.go +4 -4
@@ -30,7 +30,7 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
3030 defer release()
3131 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
3232 theme := backdropTheme()
33- ac.setTheme(theme)
33+ ac.setThemeForTest(theme)
3434 client := vt.NewScreen(80, 25)
3535 pane := sess.tabs[0].focusedPane()
3636 pane.screen.Write([]byte("\x1b[38;2;180;90;30mX"))
@@ -96,7 +96,7 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
9696 p, release := newBlockingPTY(t)
9797 defer release()
9898 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
99- ac.setTheme(backdropTheme())
99+ ac.setThemeForTest(backdropTheme())
100100 client := vt.NewScreen(80, 25)
101101 pane := sess.tabs[0].focusedPane()
102102 pane.screen.Write([]byte("X"))
@@ -117,7 +117,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
117117 p, release := newBlockingPTY(t)
118118 defer release()
119119 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
120- ac.setTheme(backdropTheme())
120+ ac.setThemeForTest(backdropTheme())
121121 client := vt.NewScreen(80, 25)
122122 pane := sess.tabs[0].focusedPane()
123123 pane.screen.Write([]byte("X"))
@@ -1178,7 +1178,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
11781178 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
11791179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
11801180
1181- frame, damage := composeCopyClientFrame(mode, target, base, barState{})
1181+ frame, damage := composeCopyClientFrame(mode, target, base, resolveStyles(nil))
11821182
11831183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
11841184 require.Equal(t, "##ab ", rowText(frame.Row(3)))
M internal/usecase/daemon/status.go +17 -20
@@ -45,17 +45,17 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
4545 }
4646
4747 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
48- clearStatusRow(row)
48+ clearStatusRow(row, styles.SurfaceBar)
4949 x := 0
5050 labels := fitTabLabels(status.tabs, len(row), topRight)
5151 for i, w := range status.tabs {
52- baseStyle := styles.StatusBar
53- nameStyle := styles.TabName
54- titleStyle := styles.TabTitle
52+ baseStyle := styles.SurfaceInactive
53+ nameStyle := styles.TabInactive
54+ titleStyle := styles.TabInactiveTitle
5555 if w.active {
56- baseStyle = styles.Accent
57- nameStyle = styles.TabNameActive
58- titleStyle = styles.TabTitleActive
56+ baseStyle = styles.SurfaceActive
57+ nameStyle = styles.TabActive
58+ titleStyle = styles.TabActiveTitle
5959 }
6060 label := labels[i]
6161 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
@@ -66,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6666 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
6767 writeStatusText(row, &x, " ", baseStyle)
6868 }
69- drawRightPlainText(row, topRight, x, styles.StatusBar)
69+ drawRightPlainText(row, topRight, x, styles.SurfaceBar)
7070 }
7171
7272 func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
73- clearStatusRow(row)
73+ clearStatusRow(row, styles.SurfaceBar)
7474 x := 0
7575 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
76- writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent)
76+ writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
7777 if state.rankedRecent != nil {
7878 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
79- style := styles.StatusBar // contextual ranks deliberately do not fade.
79+ style := styles.SurfaceInactive // contextual ranks deliberately do not fade.
8080 if sess.selected {
81- style = styles.Accent
81+ style = styles.SurfaceActive
8282 }
8383 drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
8484 }
8585 } else {
8686 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8787 for i, sess := range fittedMRU {
88- style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU))
88+ style := styles.MRUStyle(i, len(fittedMRU))
8989 drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)
9090 }
9191 }
92- drawRightPlainText(row, rightText, x, styles.StatusBar)
92+ drawRightPlainText(row, rightText, x, styles.SurfaceBar)
9393 }
9494
9595 type rankedRecent struct {
@@ -147,9 +147,10 @@ func statusTextWidth(text string) int {
147147 return width
148148 }
149149
150-func clearStatusRow(row []renderer.Cell) {
150+func clearStatusRow(row []renderer.Cell, style renderer.Style) {
151+ blank := renderer.Cell{Rune: ' ', Style: style}
151152 for i := range row {
152- row[i] = renderer.BlankCell()
153+ row[i] = blank
153154 }
154155 }
155156
@@ -426,10 +427,6 @@ func mruFutureRightReserve(rowLen int) int {
426427 return reserve
427428 }
428429
429-func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
430- return themeui.MRUFade(base, t, i, count)
431-}
432-
433430 // writeBell draws the attention glyph on its visible beat, or a plain cell in
434431 // base's style on its blank beat, so the bell never leaves a bare
435432 // default-background hole in a themed bar.
M internal/usecase/daemon/status_test.go +117 -27
@@ -408,7 +408,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
408408 win := sess.activeTab()
409409 win.focusedPane().screen.Resize(12, 2)
410410 win.size = domain.Size{Cols: 12, Rows: 2}
411- ac.setTheme(themeui.Theme{
411+ ac.setThemeForTest(themeui.Theme{
412412 Foreground: renderer.RGB{R: 220, G: 220, B: 220},
413413 Background: renderer.RGB{R: 10, G: 20, B: 30},
414414 HasFG: true,
@@ -417,9 +417,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
417417 Known: true,
418418 })
419419
420- bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
420+ applied := ac.getAppliedTheme()
421+ bars := barState{status: sess.statusSegments(true), theme: applied.Raw}
421422 composed := composeFrame(capturedRenderState{
422- reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme,
423+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme, styles: applied.Resolved.Styles, styleGeneration: applied.Generation,
423424 }, composeCacheInput{})
424425 out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
425426
@@ -437,9 +438,9 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
437438
438439 d.applyTheme(sess, ac, msg)
439440
440- require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
441- require.True(t, ac.getTheme().SchemeKnown)
442- require.True(t, ac.getTheme().Light)
441+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getAppliedTheme().Raw.Foreground)
442+ require.True(t, ac.getAppliedTheme().Raw.SchemeKnown)
443+ require.True(t, ac.getAppliedTheme().Raw.Light)
443444 assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6})
444445 assertSessionColorScheme(t, sess, true)
445446 }
@@ -493,7 +494,7 @@ func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
493494 TrueColor: true, SchemeKnown: true, Light: !tc.want.Light,
494495 })
495496
496- require.Equal(t, tc.want, ac.getTheme())
497+ require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
497498 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
498499 assertSessionColorScheme(t, sess, tc.want.Light)
499500 })
@@ -529,7 +530,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
529530
530531 ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
531532
532- require.Equal(t, tc.want, ac.getTheme())
533+ require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
533534 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
534535 assertSessionColorScheme(t, sess, tc.want.Light)
535536 })
@@ -1199,28 +1200,22 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
11991200 }
12001201
12011202 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1202- theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1203- styles := themeui.NewStyles(theme)
1204- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1203+ theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
1204+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1205+ theme.Palette[10] = theme.Palette[2]
1206+ theme.PaletteKnown = 1<<2 | 1<<10
1207+ resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1208+ state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
12051209 row := make([]renderer.Cell, 16)
12061210
1207- drawStatusBarState(row, state, styles)
1211+ drawStatusBarState(row, state, resolved.Styles)
1212+
1213+ for index, cell := range []int{4, 7, 10} {
1214+ require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)))
1215+ }
1216+ require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar")
12081217
1209- firstFG := row[4].Style.ForegroundRGB.R
1210- secondFG := row[7].Style.ForegroundRGB.R
1211- thirdFG := row[10].Style.ForegroundRGB.R
1212- require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG)
1213- require.Greater(t, firstFG, secondFG)
1214- require.Greater(t, secondFG, thirdFG)
1215-
1216- firstBG := row[4].Style.BackgroundRGB.R
1217- secondBG := row[7].Style.BackgroundRGB.R
1218- thirdBG := row[10].Style.BackgroundRGB.R
1219- require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG)
1220- require.Greater(t, firstBG, secondBG)
1221- require.Greater(t, secondBG, thirdBG)
1222-
1223- plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
1218+ plain := resolveStyles(nil).MRUStyle(1, 3)
12241219 require.False(t, plain.HasForegroundRGB)
12251220 require.False(t, plain.HasBackgroundRGB)
12261221 }
@@ -1402,6 +1397,101 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
14021397 }
14031398 }
14041399
1400+func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1401+ theme := themeui.Theme{
1402+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
1403+ Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
1404+ HasFG: true,
1405+ HasBG: true,
1406+ TrueColor: true,
1407+ Known: true,
1408+ UsePalette: true,
1409+ }
1410+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1411+ theme.Palette[10] = theme.Palette[2]
1412+ theme.PaletteKnown = 1<<2 | 1<<10
1413+ resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1414+ styles := resolved.Styles
1415+
1416+ t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) {
1417+ row := make([]renderer.Cell, 48)
1418+ status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}}
1419+ drawTopBarSnapshot(row, status, 0, "clock", styles)
1420+
1421+ for _, cell := range row[28:40] {
1422+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar")
1423+ }
1424+ require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive")
1425+ require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text")
1426+ selected := strings.Index(rowText(row), "selected")
1427+ require.GreaterOrEqual(t, selected, 0)
1428+ require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling")
1429+ require.True(t, row[selected].Style.Bold, "active tab stays bold")
1430+ })
1431+
1432+ t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) {
1433+ row := make([]renderer.Cell, 48)
1434+ state := barState{
1435+ status: statusSnapshot{session: "current"},
1436+ bottomRight: "script ok",
1437+ mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}},
1438+ }
1439+ drawStatusBarState(row, state, styles)
1440+ for i := range len(" current ") {
1441+ require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive")
1442+ }
1443+ for index, name := range []string{"new", "middle", "old"} {
1444+ at := strings.Index(rowText(row), name)
1445+ require.GreaterOrEqual(t, at, 0)
1446+ require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name)
1447+ }
1448+ for _, cell := range row[30:38] {
1449+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar")
1450+ }
1451+ at := strings.Index(rowText(row), "script ok")
1452+ require.GreaterOrEqual(t, at, 0)
1453+ require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar")
1454+
1455+ ranked := make([]renderer.Cell, 48)
1456+ drawStatusBarState(ranked, barState{
1457+ status: statusSnapshot{session: "current"},
1458+ rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}},
1459+ }, styles)
1460+ inactive := strings.Index(rowText(ranked), "inactive")
1461+ chosen := strings.Index(rowText(ranked), "chosen")
1462+ require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive")
1463+ require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling")
1464+ })
1465+
1466+ t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) {
1467+ empty := make([]renderer.Cell, 20)
1468+ drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles)
1469+ for _, cell := range empty[len(" current "):] {
1470+ require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar")
1471+ }
1472+
1473+ single := make([]renderer.Cell, 20)
1474+ drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles)
1475+ only := strings.Index(rowText(single), "only")
1476+ require.GreaterOrEqual(t, only, 0)
1477+ require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")
1478+ })
1479+
1480+ t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
1481+ row := make([]renderer.Cell, 32)
1482+ drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles)
1483+ bells := make([]int, 0, 2)
1484+ for i, cell := range row {
1485+ if cell.Rune == ui.AttentionGlyph {
1486+ bells = append(bells, i)
1487+ }
1488+ }
1489+ require.Len(t, bells, 2)
1490+ require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention")
1491+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface")
1492+ })
1493+}
1494+
14051495 func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
14061496 p1, releasePTY1 := newBlockingPTY(t)
14071497 p2, releasePTY2 := newBlockingPTY(t)
A internal/usecase/daemon/theme_cache_test.go +104 -0
@@ -0,0 +1,104 @@
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/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ themeui "github.com/bnema/vev/internal/usecase/theme"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+func cacheTheme() themeui.Theme {
16+ palette := [16]renderer.RGB{}
17+ palette[2] = renderer.RGB{R: 30, G: 190, B: 150}
18+ palette[10] = palette[2]
19+ return themeui.Theme{
20+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
21+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
22+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
23+ }
24+}
25+
26+func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {
27+ d, sess, ac, _ := newManualSessionWithPTYs(t)
28+ raw := cacheTheme()
29+ d.applyTheme(sess, ac, ports.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
30+ before := ac.getAppliedTheme()
31+
32+ cfg := domain.Defaults()
33+ cfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}
34+ d.ApplyConfig(cfg)
35+ after := ac.getAppliedTheme()
36+
37+ require.Equal(t, before.Raw, after.Raw, "an accent-only reload retains the definitive client palette")
38+ require.Greater(t, after.Generation, before.Generation)
39+ require.Equal(t, themeui.Resolve(after.Raw, cfg.ThemeAccent), after.Resolved)
40+ require.Equal(t, uint8(2), after.Resolved.Accent.Slot)
41+}
42+
43+func TestComposeCacheInvalidatesOnStyleGenerationWithoutPaneRecoloring(t *testing.T) {
44+ raw := cacheTheme()
45+ first := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
46+ second := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
47+ pane := renderer.NewFrame(4, 1)
48+ pane.Set(0, 0, renderer.Cell{Rune: 'P', Style: renderer.Style{Foreground: 196}})
49+ state := capturedRenderState{
50+ reset: false, theme: raw, styles: first.Styles, styleGeneration: 1,
51+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "p", valid: true, fingerprint: "same", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}}},
52+ panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, damage: []renderer.Damage{renderer.FullRedraw()}}},
53+ }
54+ committed := composeFrame(state, composeCacheInput{})
55+ state.reset, state.styles, state.styleGeneration, state.panes[0].damage = false, second.Styles, 2, nil
56+ out := composeFrame(state, committed.cache, composeCacheInput{})
57+
58+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
59+ require.Equal(t, pane.At(0, 0), out.frame.At(0, 1), "captured application cells must not receive chrome styles")
60+}
61+
62+func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
63+ raw := cacheTheme()
64+ resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
65+ cell := renderer.Cell{Rune: 'X', Style: renderer.Style{Foreground: 196}}
66+ focused, inactive := renderer.NewFrame(2, 1), renderer.NewFrame(2, 1)
67+ focused.Set(0, 0, cell)
68+ inactive.Set(0, 0, cell)
69+ placements := []layout.Placement{{ID: "focused", Content: domain.Rect{Width: 2, Height: 1}}, {ID: "inactive", Content: domain.Rect{X: 2, Width: 2, Height: 1}}}
70+ state := capturedRenderState{
71+ reset: true, theme: raw, styles: resolved.Styles, styleGeneration: 1,
72+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "focused", valid: true, placements: placements},
73+ panes: []capturedPaneRenderState{{id: "focused", frame: focused, focused: true, placement: placements[0], damage: []renderer.Damage{renderer.FullRedraw()}}, {id: "inactive", frame: inactive, placement: placements[1], damage: []renderer.Damage{renderer.FullRedraw()}}},
74+ }
75+ out := composeFrame(state, composeCacheInput{})
76+ require.Equal(t, cell, out.frame.At(0, 1))
77+ require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)
78+}
79+
80+func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
81+ raw := cacheTheme()
82+ resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
83+ neutral := renderer.DefaultStyle()
84+ neutral.HasForegroundRGB = true
85+ neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40)
86+ want := themeui.NewDimmer(raw).Dim(neutral)
87+ accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted)
88+
89+ split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw)
90+ split.styles = resolved.Styles
91+ splitOut := composeFrame(split, composeCacheInput{})
92+ require.Equal(t, 'L', splitOut.frame.At(0, 1).Rune, "split geometry and pane content must remain unchanged")
93+ require.Equal(t, 'R', splitOut.frame.At(21, 1).Rune, "split geometry and pane content must remain unchanged")
94+ require.Equal(t, want, splitOut.frame.At(20, 1).Style, "divider must dim a neutral input")
95+ require.NotEqual(t, accentDerived, splitOut.frame.At(20, 1).Style, "divider must exclude accent-ramp styles from Dimmer")
96+
97+ titles := cachedStackTitleState("inactive", 1, true)
98+ titles.theme, titles.styles = raw, resolved.Styles
99+ titleOut := composeFrame(titles, composeCacheInput{})
100+ require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged")
101+ require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input")
102+ require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer")
103+ require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged")
104+}
A internal/usecase/daemon/theme_integration_test.go +183 -0
@@ -0,0 +1,183 @@
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/ports"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme {
15+ out := ports.Theme{
16+ Foreground: foreground, Background: background,
17+ HasForeground: true, HasBackground: true, TrueColor: true,
18+ SchemeKnown: true,
19+ }
20+ for slot, color := range palette {
21+ out.Palette[slot] = color
22+ out.PaletteKnown |= 1 << slot
23+ }
24+ return out
25+}
26+
27+func chromeStyleSnapshot(t *testing.T, styles themeui.Styles) []byte {
28+ t.Helper()
29+ frame := renderer.NewFrame(6, 1)
30+ for x, style := range []renderer.Style{
31+ styles.SurfaceBar, styles.SurfaceInactive, styles.SurfaceRecent,
32+ styles.SurfaceActive, styles.BorderMuted, styles.BorderActive,
33+ } {
34+ frame.Set(x, 0, renderer.Cell{Rune: rune('A' + x), Style: style})
35+ }
36+ out, err := renderer.New(renderer.Capabilities{}).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
37+ require.NoError(t, err)
38+ return out
39+}
40+
41+func TestAccentLifecycleGenerationDaemonResolutionAndReload(t *testing.T) {
42+ p, release := newBlockingPTY(t)
43+ defer release()
44+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
45+
46+ oldForeground, oldBackground := renderer.RGB{R: 230, G: 230, B: 230}, renderer.RGB{R: 8, G: 9, B: 10}
47+ old := lifecycleTheme(oldForeground, oldBackground, map[uint8]renderer.RGB{
48+ 4: {R: 108, G: 155, B: 217}, 12: {R: 108, G: 155, B: 217},
49+ })
50+ d.applyTheme(sess, ac, old)
51+ beforeClear := ac.getAppliedTheme()
52+ require.True(t, beforeClear.Resolved.Accent.Known)
53+ require.Equal(t, uint8(4), beforeClear.Resolved.Accent.Slot)
54+
55+ // A replacement generation's first publication retains pane defaults but
56+ // clears every palette bit before daemon style resolution.
57+ cleared := old
58+ cleared.PaletteKnown = 0
59+ cleared.Palette = [16]renderer.RGB{}
60+ cleared.Light = true
61+ d.applyTheme(sess, ac, cleared)
62+ clearedApplied := ac.getAppliedTheme()
63+ require.Equal(t, oldForeground, clearedApplied.Raw.Foreground)
64+ require.Equal(t, oldBackground, clearedApplied.Raw.Background)
65+ require.True(t, clearedApplied.Raw.Light)
66+ require.Zero(t, clearedApplied.Raw.PaletteKnown)
67+ require.False(t, clearedApplied.Resolved.Accent.Known)
68+ require.Equal(t, beforeClear.Generation+1, clearedApplied.Generation)
69+ assertSessionDefaultColors(t, sess, oldForeground, oldBackground)
70+
71+ newForeground, newBackground := renderer.RGB{R: 240, G: 240, B: 240}, renderer.RGB{R: 12, G: 13, B: 14}
72+ teal := renderer.RGB{R: 125, G: 181, B: 181}
73+ definitive := lifecycleTheme(newForeground, newBackground, map[uint8]renderer.RGB{
74+ 2: teal, 10: teal, 14: teal,
75+ 4: {R: 108, G: 155, B: 217}, 12: {R: 108, G: 155, B: 217},
76+ })
77+ d.applyTheme(sess, ac, definitive)
78+ resolved := ac.getAppliedTheme()
79+ require.Equal(t, clearedApplied.Generation+1, resolved.Generation)
80+ require.True(t, resolved.Resolved.Accent.Known)
81+ require.Equal(t, uint8(2), resolved.Resolved.Accent.Slot)
82+ require.Equal(t, teal, resolved.Resolved.Accent.RGB)
83+ assertSessionDefaultColors(t, sess, newForeground, newBackground)
84+
85+ // Policy-only reloads resolve the retained definitive snapshot: no client
86+ // query or intermediate Theme publication is involved.
87+ explicit := domain.Defaults()
88+ explicit.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 4}
89+ d.ApplyConfig(explicit)
90+ reloaded := ac.getAppliedTheme()
91+ require.Equal(t, resolved.Raw, reloaded.Raw)
92+ require.Greater(t, reloaded.Generation, resolved.Generation)
93+ require.Equal(t, uint8(4), reloaded.Resolved.Accent.Slot)
94+ require.Equal(t, definitive.Palette, ac.getClientTheme().Palette)
95+
96+ unknown := explicit
97+ unknown.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 6}
98+ d.ApplyConfig(unknown)
99+ require.False(t, ac.getAppliedTheme().Resolved.Accent.Known, "an explicit unknown slot must not substitute another slot")
100+
101+ paletteOff := explicit
102+ paletteOff.ThemePalette = false
103+ d.ApplyConfig(paletteOff)
104+ off := ac.getAppliedTheme()
105+ require.False(t, off.Raw.UsePalette)
106+ require.False(t, off.Resolved.Accent.Known)
107+ assertSessionDefaultColors(t, sess, newForeground, newBackground)
108+
109+ for _, tc := range []struct {
110+ name string
111+ mode domain.ThemeMode
112+ want themeui.Theme
113+ }{
114+ {name: "forced dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
115+ {name: "forced light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
116+ } {
117+ t.Run(tc.name, func(t *testing.T) {
118+ cfg := domain.Defaults()
119+ cfg.Theme = tc.mode
120+ d.ApplyConfig(cfg)
121+ applied := ac.getAppliedTheme()
122+ require.Equal(t, tc.want, applied.Raw)
123+ require.False(t, applied.Resolved.Accent.Known)
124+ assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
125+ })
126+ }
127+
128+ // A timeout-finalized partial report is still one definitive daemon apply;
129+ // an unavailable explicit slot remains neutral.
130+ partialCfg := domain.Defaults()
131+ partialCfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 6}
132+ d.ApplyConfig(partialCfg)
133+ beforePartial := ac.getAppliedTheme()
134+ partial := lifecycleTheme(newForeground, newBackground, map[uint8]renderer.RGB{2: teal})
135+ d.applyTheme(sess, ac, partial)
136+ partialApplied := ac.getAppliedTheme()
137+ require.Equal(t, beforePartial.Generation+1, partialApplied.Generation)
138+ require.Equal(t, uint16(1<<2), ac.getClientTheme().PaletteKnown)
139+ require.False(t, partialApplied.Resolved.Accent.Known)
140+}
141+
142+func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
143+ raw := lifecycleTheme(
144+ renderer.RGB{R: 230, G: 230, B: 230}, renderer.RGB{R: 8, G: 9, B: 10},
145+ map[uint8]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}},
146+ )
147+ tests := []struct {
148+ name string
149+ config domain.Config
150+ want string
151+ }{
152+ {
153+ name: "palette off",
154+ config: func() domain.Config {
155+ cfg := domain.Defaults()
156+ cfg.ThemePalette = false
157+ return cfg
158+ }(),
159+ want: "\x1b[1;1HABC\x1b[0;7mD\x1b[0mEF\x1b[0m",
160+ },
161+ {
162+ name: "forced dark",
163+ config: domain.Config{Theme: domain.ThemeDark},
164+ want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;216;216;216;48;2;72;72;72mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
165+ },
166+ {
167+ name: "forced light",
168+ config: domain.Config{Theme: domain.ThemeLight},
169+ want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;32;32;32;48;2;194;194;194mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
170+ },
171+ }
172+ for _, tt := range tests {
173+ t.Run(tt.name, func(t *testing.T) {
174+ d := newTestDaemon(t, nil, stubClock{})
175+ d.ApplyConfig(tt.config)
176+ got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
177+ Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
178+ HasFG: true, HasBG: true, TrueColor: true,
179+ }).Resolved.Styles)
180+ require.Equal(t, []byte(tt.want), got)
181+ })
182+ }
183+}
M internal/usecase/daemon/theme_resolve.go +6 -1
@@ -13,6 +13,11 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1313 }
1414
1515 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
16+ // Resolve while applying, never while composing. The theme mutex only
17+ // publishes the completed value and is not held across session/tab/pane
18+ // locks, preserving the daemon lock order.
19+ applied := d.resolveAppliedTheme(t)
20+ t = applied.Raw
1621 sess.mu.Lock()
1722 if ac != nil {
1823 if sess.client != ac {
@@ -27,7 +32,7 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2732 sess.mu.Unlock()
2833
2934 if ac != nil {
30- ac.setTheme(t)
35+ ac.setAppliedTheme(applied)
3136 }
3237 for _, tb := range tabs {
3338 tb.mu.Lock()
M internal/usecase/palette/model.go +11 -3
@@ -11,6 +11,10 @@ import (
1111 )
1212
1313 type RenderStyles struct {
14+ // Base fills unused modal interior cells; Row is the ordinary inactive
15+ // result surface. They are separate to retain the chrome hierarchy.
16+ Base renderer.Style
17+ Row renderer.Style
1418 Selection renderer.Style
1519 Description renderer.Style
1620 }
@@ -53,7 +57,8 @@ func DefaultRenderStyles() RenderStyles {
5357 selection.Inverse = true
5458 description := renderer.DefaultStyle()
5559 description.Italic = true
56- return RenderStyles{Selection: selection, Description: description}
60+ base := renderer.DefaultStyle()
61+ return RenderStyles{Base: base, Row: base, Selection: selection, Description: description}
5762 }
5863
5964 func (m *Model) Insert(r rune) {
@@ -235,11 +240,14 @@ func (m *Model) clamp() {
235240
236241 func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
237242 styles := opts.Styles
243+ if styles == (RenderStyles{}) {
244+ styles = DefaultRenderStyles()
245+ }
238246 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
239247 if frame.Width == 0 || frame.Height == 0 {
240248 return frame
241249 }
242- base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description
250+ base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
243251 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
244252 if m == nil {
245253 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
@@ -269,7 +277,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
269277 break
270278 }
271279 match := m.matches[idx]
272- style := base
280+ style := row
273281 if idx == m.selected {
274282 style = selection
275283 }
M internal/usecase/palette/model_test.go +66 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/vev/internal/domain"
99 "github.com/bnema/vev/internal/usecase/command"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
1011 "github.com/bnema/vev/pkg/renderer"
1112 "github.com/stretchr/testify/require"
1213 )
@@ -270,6 +271,59 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
270271 }
271272 }
272273
274+func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
275+ paletteColors := [16]renderer.RGB{}
276+ paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
277+ paletteColors[10] = paletteColors[2]
278+ accentTheme := themeui.Theme{
279+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
280+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
281+ Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
282+ }
283+ indexedTheme := accentTheme
284+ indexedTheme.TrueColor = false
285+ paletteOffTheme := accentTheme
286+ paletteOffTheme.UsePalette = false
287+ neutralTheme := paletteOffTheme
288+ neutralTheme.PaletteKnown = 0
289+
290+ tests := []struct {
291+ name string
292+ theme themeui.Theme
293+ policy domain.ThemeAccent
294+ }{
295+ {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
296+ {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
297+ {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
298+ {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
299+ {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
300+ {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
301+ }
302+
303+ for _, tt := range tests {
304+ t.Run(tt.name, func(t *testing.T) {
305+ styles := themeui.Resolve(tt.theme, tt.policy).Styles
306+ model := New(CommandResults([]command.Command{
307+ cmd("ONE", "One", "first description"),
308+ cmd("TWO", "Two", "second description"),
309+ }))
310+ model.Down()
311+ frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
312+ Base: styles.PickerBase, Row: styles.SurfaceInactive,
313+ Selection: styles.PickerSelection, Description: styles.PickerDescription,
314+ }})
315+ description := frame.At(4, 1).Style
316+ require.Equal(t, styles.SurfaceInactive.Background, description.Background)
317+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
318+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
319+ if tt.name == "indexed only" {
320+ require.Equal(t, 2, description.Foreground)
321+ require.False(t, description.HasBackgroundRGB)
322+ }
323+ })
324+ }
325+}
326+
273327 func TestModelCompleteSelected(t *testing.T) {
274328 jrs := cmd("JRS", "Jump", "Jump to recent session")
275329 jrs.Arguments = command.ArgumentsRequired
@@ -400,3 +454,15 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
400454 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
401455 }
402456 }
457+
458+func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
459+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
460+ base := renderer.Style{Foreground: 1, Background: 2}
461+ row := renderer.Style{Foreground: 3, Background: 4}
462+ selection := renderer.Style{Foreground: 5, Background: 6}
463+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
464+
465+ require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
466+ require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
467+ require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
468+}
M internal/usecase/picker/model.go +7 -3
@@ -42,8 +42,11 @@ type RenderStyles struct {
4242 SelectionMuted renderer.Style // selected row detail segment
4343 Name renderer.Style // non-selected name segment
4444 Detail renderer.Style // non-selected detail segment
45- Base renderer.Style // non-selected fill + suffixes
46- Separator renderer.Style // preview separator
45+ // Background fills otherwise unused interior cells. Base owns ordinary
46+ // rows, preserving a distinct inactive surface from modal chrome.
47+ Background renderer.Style
48+ Base renderer.Style // non-selected row fill + suffixes
49+ Separator renderer.Style // preview separator
4750 }
4851
4952 func defaultRenderStyles() RenderStyles {
@@ -52,7 +55,7 @@ func defaultRenderStyles() RenderStyles {
5255 base := renderer.DefaultStyle()
5356 separator := renderer.DefaultStyle()
5457 separator.Attrs = renderer.AttrDim
55- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator}
58+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator}
5659 }
5760
5861 type Target struct {
@@ -203,6 +206,7 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle
203206 if len(styles) > 0 {
204207 styleSet = styles[0]
205208 }
209+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
206210 m.renderList(frame, layout.List, styleSet)
207211 switch layout.Mode {
208212 case LayoutHorizontal:
M internal/usecase/picker/model_test.go +79 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/internal/domain"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
910 "github.com/stretchr/testify/require"
@@ -332,3 +333,81 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
332333 func cell(r rune) renderer.Cell {
333334 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
334335 }
336+
337+func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
338+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
339+ background := renderer.Style{Foreground: 1, Background: 2}
340+ base := renderer.Style{Foreground: 3, Background: 4}
341+ selection := renderer.Style{Foreground: 5, Background: 6}
342+ frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{
343+ Background: background, Base: base, Name: base, Detail: base,
344+ Selection: selection, SelectionName: selection, SelectionMuted: selection,
345+ Separator: base,
346+ })
347+
348+ require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base")
349+ require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
350+ require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
351+}
352+
353+func TestPickerDetailKeepsInactiveSurfaceAcrossAccentFallbacks(t *testing.T) {
354+ palette := [16]renderer.RGB{}
355+ palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
356+ palette[10] = palette[2]
357+ accentTheme := themeui.Theme{
358+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
359+ HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
360+ Palette: palette, PaletteKnown: 1<<2 | 1<<10,
361+ }
362+ indexedTheme := accentTheme
363+ indexedTheme.TrueColor = false
364+ paletteOffTheme := accentTheme
365+ paletteOffTheme.UsePalette = false
366+ neutralTheme := accentTheme
367+ neutralTheme.UsePalette = false
368+ neutralTheme.PaletteKnown = 0
369+
370+ tests := []struct {
371+ name string
372+ theme themeui.Theme
373+ policy domain.ThemeAccent
374+ }{
375+ {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
376+ {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
377+ {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
378+ {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
379+ {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
380+ {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
381+ }
382+
383+ model := New([]SessionView{
384+ {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
385+ {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
386+ }, "selected", 0)
387+ for _, tt := range tests {
388+ t.Run(tt.name, func(t *testing.T) {
389+ styles := themeui.Resolve(tt.theme, tt.policy).Styles
390+ require.Equal(t, styles.SurfaceInactive.Background, styles.PickerDescription.Background)
391+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, styles.PickerDescription.HasBackgroundRGB)
392+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB)
393+ require.Equal(t, styles.SurfaceBar.Background, styles.PickerSeparator.Background)
394+ require.Equal(t, styles.SurfaceBar.HasBackgroundRGB, styles.PickerSeparator.HasBackgroundRGB)
395+ require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.PickerSeparator.BackgroundRGB)
396+ if tt.name == "indexed only" {
397+ require.Equal(t, 2, styles.PickerDescription.Foreground)
398+ require.Equal(t, 2, styles.PickerSeparator.Foreground)
399+ require.False(t, styles.PickerDescription.HasBackgroundRGB)
400+ require.False(t, styles.PickerSeparator.HasBackgroundRGB)
401+ }
402+
403+ frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
404+ Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive,
405+ Detail: styles.PickerDescription, Selection: styles.PickerSelection,
406+ SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
407+ Separator: styles.PickerSeparator,
408+ })
409+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background")
410+ require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface")
411+ })
412+ }
413+}
M internal/usecase/prompt/model.go +22 -9
@@ -6,6 +6,14 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9+// RenderStyles contains daemon-supplied semantic chrome roles. The zero value
10+// is intentionally not used by RenderStyled; isolated Render callers retain
11+// the historic neutral defaults.
12+type RenderStyles struct {
13+ Base renderer.Style
14+ Selection renderer.Style
15+}
16+
917 type Model struct {
1018 title string
1119 text ui.TextInput
@@ -55,20 +63,25 @@ func (m *Model) SetError(msg string) {
5563 }
5664
5765 func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame {
66+ base := renderer.DefaultStyle()
67+ selection := base
68+ selection.Inverse = true
69+ if len(accentStyle) > 0 {
70+ selection = accentStyle[0]
71+ }
72+ return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
73+}
74+
75+// RenderStyled renders with explicit chrome roles captured by the daemon.
76+func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
5877 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
5978 if frame.Width == 0 || frame.Height == 0 {
6079 return frame
6180 }
62- base := renderer.DefaultStyle()
63- accent := base
64- accent.Inverse = true
65- if len(accentStyle) > 0 {
66- accent = accentStyle[0]
67- }
68- ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
69- ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent)
81+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base})
82+ ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection)
7083 if frame.Height > 1 && m != nil && m.errMsg != "" {
71- ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent)
84+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection)
7285 }
7386 return frame
7487 }
M internal/usecase/prompt/model_test.go +10 -0
@@ -57,3 +57,13 @@ func TestModelRenderHandlesTinySize(t *testing.T) {
5757 require.Equal(t, 0, frame.Width)
5858 require.Equal(t, 0, frame.Height)
5959 }
60+
61+func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) {
62+ m := New(" Rename ", "x")
63+ base := renderer.Style{Foreground: 1, Background: 2}
64+ selection := renderer.Style{Foreground: 3, Background: 4}
65+ frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection})
66+
67+ require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface")
68+ require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface")
69+}
A internal/usecase/theme/accent_resolve.go +269 -0
@@ -0,0 +1,269 @@
1+package theme
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+const (
9+ accentMinChroma = 0.025
10+ accentMinBackgroundDistance = 0.08
11+ accentClusterDistance = 0.04
12+)
13+
14+// Accent is the single terminal-owned color selected for chrome. IndexedOnly
15+// means the slot may decorate foregrounds and borders, but cannot fill RGB
16+// backgrounds because terminal defaults or truecolor support are unavailable.
17+type Accent struct {
18+ RGB renderer.RGB
19+ Slot uint8
20+ Known bool
21+ IndexedOnly bool
22+}
23+
24+// The resolver only ever considers the chromatic ANSI slots in automatic
25+// mode. This immutable mapping keeps resolution bounded and independent of
26+// terminal response order without mutable package-level state.
27+const accentCandidateCount = 12
28+
29+func accentCandidateSlot(index int) uint8 {
30+ if index < 6 {
31+ return uint8(index + 1)
32+ }
33+ return uint8(index + 3)
34+}
35+
36+type accentGroup struct {
37+ members uint16
38+ size uint8
39+ pairs uint8
40+ chromaSum float64
41+ rep uint8
42+}
43+
44+// ResolveAccent deterministically selects a terminal-owned accent. It uses
45+// only fixed arrays and bitsets so this pure hot-reload operation makes no
46+// per-call heap allocations.
47+func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent {
48+ if !t.UsePalette {
49+ return Accent{}
50+ }
51+ if policy.Mode == domain.ThemeAccentSlot {
52+ return resolveExplicitAccent(t, policy.Slot)
53+ }
54+ return resolveAutoAccent(t)
55+}
56+
57+func resolveExplicitAccent(t Theme, slot uint8) Accent {
58+ if slot >= uint8(len(t.Palette)) {
59+ return Accent{}
60+ }
61+ if t.PaletteKnown&(uint16(1)<<slot) == 0 {
62+ // An explicit policy is a strict override. Even without OSC RGB for its
63+ // slot, it may still safely decorate foregrounds and borders by index.
64+ return indexedAccent(slot)
65+ }
66+ return resolvedAccent(t, t.Palette[slot], slot)
67+}
68+
69+func resolveAutoAccent(t Theme) Accent {
70+ // Background is necessary to reject colors that merely alias the terminal
71+ // surface. Without it, retain the pre-accent scheme-aware ANSI blue
72+ // foreground fallback rather than inventing an RGB background.
73+ if !t.HasBG {
74+ return schemeIndexedBlue(t)
75+ }
76+
77+ var colors [accentCandidateCount]oklab
78+ var eligible uint16
79+ background := rgbToOKLab(t.Background)
80+ for i := range accentCandidateCount {
81+ slot := accentCandidateSlot(i)
82+ if t.PaletteKnown&(uint16(1)<<slot) == 0 {
83+ continue
84+ }
85+ colors[i] = rgbToOKLab(t.Palette[slot])
86+ if accentEligible(colors[i], background) {
87+ eligible |= uint16(1) << i
88+ }
89+ }
90+ if eligible == 0 {
91+ return fallbackAutoAccent(t, background)
92+ }
93+
94+ var adjacent [accentCandidateCount]uint16
95+ for i := range accentCandidateCount {
96+ if eligible&(uint16(1)<<i) == 0 {
97+ continue
98+ }
99+ adjacent[i] = uint16(1) << i
100+ for j := i + 1; j < accentCandidateCount; j++ {
101+ if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) {
102+ continue
103+ }
104+ adjacent[i] |= uint16(1) << j
105+ adjacent[j] |= uint16(1) << i
106+ }
107+ }
108+
109+ var groups [accentCandidateCount]accentGroup
110+ var visited uint16
111+ groupCount := 0
112+ for start := range accentCandidateCount {
113+ startBit := uint16(1) << start
114+ if eligible&startBit == 0 || visited&startBit != 0 {
115+ continue
116+ }
117+ var queue [accentCandidateCount]uint8
118+ head, tail := 0, 1
119+ queue[0] = uint8(start)
120+ visited |= startBit
121+ for head < tail {
122+ i := queue[head]
123+ head++
124+ groups[groupCount].members |= uint16(1) << i
125+ neighbors := adjacent[i] &^ visited
126+ for j := range accentCandidateCount {
127+ bit := uint16(1) << j
128+ if neighbors&bit == 0 {
129+ continue
130+ }
131+ visited |= bit
132+ queue[tail] = uint8(j)
133+ tail++
134+ }
135+ }
136+ finalizeAccentGroup(&groups[groupCount], colors)
137+ groupCount++
138+ }
139+
140+ best, runner := -1, -1
141+ for i := 0; i < groupCount; i++ {
142+ if best < 0 || betterAccentGroup(groups[i], groups[best]) {
143+ runner = best
144+ best = i
145+ } else if runner < 0 || betterAccentGroup(groups[i], groups[runner]) {
146+ runner = i
147+ }
148+ }
149+ winner := groups[best]
150+ runnerSize, runnerPairs := uint8(0), uint8(0)
151+ if runner >= 0 {
152+ runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs
153+ }
154+ if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) {
155+ slot := accentCandidateSlot(int(winner.rep))
156+ return resolvedAccent(t, t.Palette[slot], slot)
157+ }
158+
159+ return fallbackAutoAccent(t, background)
160+}
161+
162+func fallbackAutoAccent(t Theme, background oklab) Accent {
163+ for _, slot := range [...]uint8{4, 12} {
164+ if t.PaletteKnown&(uint16(1)<<slot) == 0 {
165+ continue
166+ }
167+ color := rgbToOKLab(t.Palette[slot])
168+ if accentEligible(color, background) {
169+ return resolvedAccent(t, t.Palette[slot], slot)
170+ }
171+ }
172+ if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 {
173+ // A terminal-reported blue that fails eligibility deliberately does not
174+ // become an unvalidated indexed fallback.
175+ return Accent{}
176+ }
177+ return schemeIndexedBlue(t)
178+}
179+
180+func schemeIndexedBlue(t Theme) Accent {
181+ if !t.SchemeKnown {
182+ return Accent{}
183+ }
184+ if t.Light {
185+ return indexedAccent(4)
186+ }
187+ return indexedAccent(12)
188+}
189+
190+func indexedAccent(slot uint8) Accent {
191+ return Accent{Slot: slot, IndexedOnly: true}
192+}
193+
194+func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) {
195+ var representative uint8
196+ var representativeDistance float64
197+ first := true
198+ for i := range accentCandidateCount {
199+ slot := accentCandidateSlot(i)
200+ if group.members&(uint16(1)<<i) == 0 {
201+ continue
202+ }
203+ group.size++
204+ group.chromaSum += okLabChroma(colors[i])
205+ var totalDistance float64
206+ for j := range accentCandidateCount {
207+ if group.members&(uint16(1)<<j) != 0 {
208+ totalDistance += okLabDistance(colors[i], colors[j])
209+ }
210+ }
211+ if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) {
212+ first = false
213+ representative = uint8(i)
214+ representativeDistance = totalDistance
215+ }
216+ }
217+ group.rep = representative
218+ for slot := uint8(1); slot <= 6; slot++ {
219+ if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) {
220+ group.pairs++
221+ }
222+ }
223+}
224+
225+func accentGroupHasSlot(group accentGroup, slot uint8) bool {
226+ for i := range accentCandidateCount {
227+ if accentCandidateSlot(i) == slot {
228+ return group.members&(uint16(1)<<i) != 0
229+ }
230+ }
231+ return false
232+}
233+
234+func betterAccentGroup(a, b accentGroup) bool {
235+ if a.size != b.size {
236+ return a.size > b.size
237+ }
238+ if a.pairs != b.pairs {
239+ return a.pairs > b.pairs
240+ }
241+ averageA := a.chromaSum / float64(a.size)
242+ averageB := b.chromaSum / float64(b.size)
243+ if averageA != averageB {
244+ return averageA > averageB
245+ }
246+ aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12)
247+ bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12)
248+ if aBlue != bBlue {
249+ return aBlue
250+ }
251+ return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep))
252+}
253+
254+func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent {
255+ return Accent{
256+ RGB: color,
257+ Slot: slot,
258+ Known: true,
259+ IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG,
260+ }
261+}
262+
263+func accentEligible(candidate, background oklab) bool {
264+ return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance
265+}
266+
267+func accentConnected(a, b oklab) bool {
268+ return okLabDistance(a, b) <= accentClusterDistance
269+}
A internal/usecase/theme/accent_resolve_test.go +257 -0
@@ -0,0 +1,257 @@
1+package theme
2+
3+import (
4+ "math/rand"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAccentCandidateSlotsAreFixedAndAscending(t *testing.T) {
13+ want := [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14}
14+ for i, slot := range want {
15+ require.Equalf(t, slot, accentCandidateSlot(i), "candidate index %d", i)
16+ }
17+}
18+
19+func TestResolveAccentThresholds(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ color oklab
23+ background oklab
24+ want bool
25+ }{
26+ {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}},
27+ {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true},
28+ {name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true},
29+ {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}},
30+ {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
31+ {name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ require.Equal(t, tt.want, accentEligible(tt.color, tt.background))
36+ })
37+ }
38+
39+ a := oklab{}
40+ require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001}))
41+ require.True(t, accentConnected(a, oklab{L: accentClusterDistance}))
42+ require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001}))
43+}
44+
45+func TestResolveAccentAuto(t *testing.T) {
46+ teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
47+ blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}
48+ tests := []struct {
49+ name string
50+ theme Theme
51+ want Accent
52+ }{
53+ {
54+ name: "repeated teal beats repeated blue",
55+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 14: teal, 4: blue, 12: blue}),
56+ want: Accent{RGB: teal, Slot: 2, Known: true},
57+ },
58+ {
59+ name: "singleton falls back to slot four",
60+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 4: blue}),
61+ want: Accent{RGB: blue, Slot: 4, Known: true},
62+ },
63+ {
64+ name: "singleton falls back to slot twelve",
65+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 12: blue}),
66+ want: Accent{RGB: blue, Slot: 12, Known: true},
67+ },
68+ {
69+ name: "tied repeated groups fall back to blue",
70+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}),
71+ want: Accent{RGB: blue, Slot: 4, Known: true},
72+ },
73+ {
74+ name: "regular bright pair beats equal size runner up",
75+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}),
76+ want: Accent{RGB: teal, Slot: 2, Known: true},
77+ },
78+ {
79+ name: "neutral slots excluded from auto",
80+ theme: paletteTheme(map[int]renderer.RGB{0: teal, 7: teal, 8: teal, 15: teal}),
81+ want: Accent{},
82+ },
83+ {
84+ name: "low chroma is rejected",
85+ theme: paletteTheme(map[int]renderer.RGB{
86+ 1: {R: 60, G: 61, B: 60}, 9: {R: 60, G: 61, B: 60},
87+ }),
88+ want: Accent{},
89+ },
90+ {
91+ name: "near background is rejected",
92+ theme: paletteTheme(map[int]renderer.RGB{
93+ 1: {R: 9, G: 8, B: 12}, 9: {R: 9, G: 8, B: 12},
94+ }),
95+ want: Accent{},
96+ },
97+ {
98+ name: "missing foreground yields indexed fallback only",
99+ theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasFG = false; return t }(),
100+ want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},
101+ },
102+ {
103+ name: "non truecolor yields indexed fallback only",
104+ theme: func() Theme {
105+ t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
106+ t.TrueColor = false
107+ return t
108+ }(),
109+ want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true},
110+ },
111+ {
112+ name: "missing background cannot infer",
113+ theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasBG = false; return t }(),
114+ want: Accent{},
115+ },
116+ {
117+ name: "palette disabled ignores accent policy",
118+ theme: func() Theme {
119+ t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
120+ t.UsePalette = false
121+ return t
122+ }(),
123+ want: Accent{},
124+ },
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ require.Equal(t, tt.want, ResolveAccent(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}))
130+ })
131+ }
132+}
133+
134+func TestResolveAccentExplicitSlot(t *testing.T) {
135+ teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
136+ blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}
137+ tests := []struct {
138+ name string
139+ theme Theme
140+ policy domain.ThemeAccent
141+ want Accent
142+ }{
143+ {
144+ name: "known explicit neutral slot bypasses auto exclusions",
145+ theme: paletteTheme(map[int]renderer.RGB{0: teal, 2: blue, 10: blue}),
146+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0},
147+ want: Accent{RGB: teal, Slot: 0, Known: true},
148+ },
149+ {
150+ name: "known explicit slot bypasses eligibility",
151+ theme: paletteTheme(map[int]renderer.RGB{3: {R: 9, G: 8, B: 12}, 2: teal, 10: teal}),
152+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
153+ want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true},
154+ },
155+ {
156+ name: "unknown explicit slot keeps its exact indexed decoration",
157+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
158+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5},
159+ want: Accent{Slot: 5, IndexedOnly: true},
160+ },
161+ {
162+ name: "unknown final palette slot keeps its indexed decoration",
163+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
164+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15},
165+ want: Accent{Slot: 15, IndexedOnly: true},
166+ },
167+ {
168+ name: "first slot outside palette is rejected",
169+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
170+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 16},
171+ want: Accent{},
172+ },
173+ {
174+ name: "maximum uint8 slot outside palette is rejected",
175+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}),
176+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 255},
177+ want: Accent{},
178+ },
179+ {
180+ name: "explicit known slot is indexed only without truecolor prerequisites",
181+ theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),
182+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
183+ want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true},
184+ },
185+ {
186+ name: "palette off ignores explicit slot",
187+ theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.UsePalette = false; return t }(),
188+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
189+ want: Accent{},
190+ },
191+ }
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy))
195+ })
196+ }
197+}
198+
199+func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.T) {
200+ entries := []struct {
201+ slot int
202+ color renderer.RGB
203+ }{
204+ {2, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {10, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {14, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
205+ {4, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, {12, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}},
206+ }
207+ want := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
208+ rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test shuffle.
209+ for i := 0; i < 50; i++ {
210+ rng.Shuffle(len(entries), func(a, b int) { entries[a], entries[b] = entries[b], entries[a] })
211+ palette := make(map[int]renderer.RGB, len(entries))
212+ for _, entry := range entries {
213+ palette[entry.slot] = entry.color
214+ }
215+ require.Equalf(t, want, ResolveAccent(paletteTheme(palette), domain.ThemeAccent{}), "shuffle %d", i)
216+ }
217+}
218+
219+func TestResolveAccentMedoidAndTieBreak(t *testing.T) {
220+ group := accentGroup{members: 1<<0 | 1<<1 | 1<<2}
221+ colors := [accentCandidateCount]oklab{
222+ {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10},
223+ }
224+ finalizeAccentGroup(&group, colors)
225+ require.Equal(t, uint8(1), group.rep, "central member is the medoid")
226+
227+ color := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
228+ got := ResolveAccent(paletteTheme(map[int]renderer.RGB{3: color, 11: color}), domain.ThemeAccent{})
229+ require.Equal(t, Accent{RGB: color, Slot: 3, Known: true}, got)
230+}
231+
232+func TestResolveAccentAllocs(t *testing.T) {
233+ theme := paletteTheme(map[int]renderer.RGB{
234+ 2: {R: 0x7d, G: 0xb5, B: 0xb5}, 10: {R: 0x7d, G: 0xb5, B: 0xb5}, 14: {R: 0x7d, G: 0xb5, B: 0xb5},
235+ 4: {R: 0x6c, G: 0x9b, B: 0xd9}, 12: {R: 0x6c, G: 0x9b, B: 0xd9},
236+ })
237+ require.Zero(t, testing.AllocsPerRun(1000, func() {
238+ _ = ResolveAccent(theme, domain.ThemeAccent{})
239+ }))
240+}
241+
242+func paletteTheme(colors map[int]renderer.RGB) Theme {
243+ theme := Theme{
244+ Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
245+ Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
246+ HasFG: true,
247+ HasBG: true,
248+ TrueColor: true,
249+ Known: true,
250+ UsePalette: true,
251+ }
252+ for slot, color := range colors {
253+ theme.Palette[slot] = color
254+ theme.PaletteKnown |= 1 << slot
255+ }
256+ return theme
257+}
A internal/usecase/theme/benchmark_test.go +71 -0
@@ -0,0 +1,71 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+var (
11+ benchmarkStylesSink Styles
12+ benchmarkResolvedSink ResolvedTheme
13+)
14+
15+func benchmarkPaletteTheme() Theme {
16+ return Theme{
17+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
18+ Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
19+ Palette: [16]renderer.RGB{
20+ 2: {R: 0x7d, G: 0xb5, B: 0xb5},
21+ 4: {R: 0x6c, G: 0x9b, B: 0xd9},
22+ 10: {R: 0x7d, G: 0xb5, B: 0xb5},
23+ 12: {R: 0x6c, G: 0x9b, B: 0xd9},
24+ 14: {R: 0x7d, G: 0xb5, B: 0xb5},
25+ },
26+ PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
27+ HasFG: true,
28+ HasBG: true,
29+ TrueColor: true,
30+ Known: true,
31+ SchemeKnown: true,
32+ UsePalette: true,
33+ }
34+}
35+
36+func TestNewStylesAllocations(t *testing.T) {
37+ theme := benchmarkPaletteTheme()
38+ allocs := testing.AllocsPerRun(1000, func() {
39+ benchmarkStylesSink = NewStyles(theme)
40+ })
41+ if allocs != 0 {
42+ t.Fatalf("NewStyles allocations/op = %v, want 0", allocs)
43+ }
44+}
45+
46+func BenchmarkNewStyles(b *testing.B) {
47+ theme := benchmarkPaletteTheme()
48+ b.ReportAllocs()
49+ for b.Loop() {
50+ benchmarkStylesSink = NewStyles(theme)
51+ }
52+}
53+
54+func TestResolveAllocations(t *testing.T) {
55+ theme := benchmarkPaletteTheme()
56+ allocs := testing.AllocsPerRun(1000, func() {
57+ benchmarkResolvedSink = Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
58+ })
59+ if allocs != 0 {
60+ t.Fatalf("Resolve allocations/op = %v, want 0", allocs)
61+ }
62+}
63+
64+func BenchmarkResolve(b *testing.B) {
65+ theme := benchmarkPaletteTheme()
66+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
67+ b.ReportAllocs()
68+ for b.Loop() {
69+ benchmarkResolvedSink = Resolve(theme, policy)
70+ }
71+}
A internal/usecase/theme/color_oklab.go +108 -0
@@ -0,0 +1,108 @@
1+package theme
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// oklab is the perceptually uniform OKLab representation of an sRGB color.
10+type oklab struct {
11+ L float64
12+ A float64
13+ B float64
14+}
15+
16+// rgbToOKLab converts an 8-bit sRGB color to OKLab via linear sRGB and LMS.
17+func rgbToOKLab(color renderer.RGB) oklab {
18+ r := srgbToLinear(color.R)
19+ g := srgbToLinear(color.G)
20+ b := srgbToLinear(color.B)
21+
22+ l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b)
23+ m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b)
24+ s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b)
25+
26+ return oklab{
27+ L: 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
28+ A: 1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
29+ B: 0.0259040371*l + 0.7827717662*m - 0.8086757660*s,
30+ }
31+}
32+
33+// okLabToRGB converts OKLab to sRGB. Linear channels are clamped before
34+// transfer encoding so out-of-gamut intermediate values are deterministic.
35+func okLabToRGB(color oklab) renderer.RGB {
36+ l := cube(color.L + 0.3963377774*color.A + 0.2158037573*color.B)
37+ m := cube(color.L - 0.1055613458*color.A - 0.0638541728*color.B)
38+ s := cube(color.L - 0.0894841775*color.A - 1.2914855480*color.B)
39+
40+ return renderer.RGB{
41+ R: linearToSRGB(4.0767416621*l - 3.3077115913*m + 0.2309699292*s),
42+ G: linearToSRGB(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s),
43+ B: linearToSRGB(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s),
44+ }
45+}
46+
47+func okLabDistance(a, b oklab) float64 {
48+ return math.Sqrt(square(a.L-b.L) + square(a.A-b.A) + square(a.B-b.B))
49+}
50+
51+func okLabChroma(color oklab) float64 {
52+ return math.Hypot(color.A, color.B)
53+}
54+
55+// okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
56+// round-trip quantization changing terminal-provided colors.
57+func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
58+ weight = clampUnit(weight)
59+ if weight == 0 {
60+ return a
61+ }
62+ if weight == 1 {
63+ return b
64+ }
65+ start := rgbToOKLab(a)
66+ end := rgbToOKLab(b)
67+ return okLabToRGB(oklab{
68+ L: start.L + (end.L-start.L)*weight,
69+ A: start.A + (end.A-start.A)*weight,
70+ B: start.B + (end.B-start.B)*weight,
71+ })
72+}
73+
74+func srgbToLinear(channel uint8) float64 {
75+ value := float64(channel) / 255
76+ if value <= 0.04045 {
77+ return value / 12.92
78+ }
79+ return math.Pow((value+0.055)/1.055, 2.4)
80+}
81+
82+func linearToSRGB(value float64) uint8 {
83+ value = clampUnit(value)
84+ if value <= 0.0031308 {
85+ value *= 12.92
86+ } else {
87+ value = 1.055*math.Pow(value, 1/2.4) - 0.055
88+ }
89+ return uint8(value*255 + 0.5)
90+}
91+
92+func clampUnit(value float64) float64 {
93+ if math.IsNaN(value) || value <= 0 {
94+ return 0
95+ }
96+ if value >= 1 {
97+ return 1
98+ }
99+ return value
100+}
101+
102+func square(value float64) float64 {
103+ return value * value
104+}
105+
106+func cube(value float64) float64 {
107+ return value * value * value
108+}
A internal/usecase/theme/color_oklab_test.go +101 -0
@@ -0,0 +1,101 @@
1+package theme
2+
3+import (
4+ "math"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestRGBToOKLabKnownValues(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ rgb renderer.RGB
14+ want oklab
15+ }{
16+ {name: "black", rgb: renderer.RGB{}, want: oklab{}},
17+ {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}},
18+ {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ got := rgbToOKLab(tt.rgb)
24+ if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 {
25+ t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want)
26+ }
27+ })
28+ }
29+}
30+
31+func TestOKLabRGBRoundTrip(t *testing.T) {
32+ colors := []renderer.RGB{
33+ {},
34+ {R: 255, G: 255, B: 255},
35+ {R: 255},
36+ {G: 255},
37+ {B: 255},
38+ {R: 0x7d, G: 0xb5, B: 0xb5},
39+ {R: 0x6c, G: 0x9b, B: 0xd9},
40+ }
41+ for _, color := range colors {
42+ t.Run("round_trip", func(t *testing.T) {
43+ got := okLabToRGB(rgbToOKLab(color))
44+ if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 {
45+ t.Fatalf("round trip %v = %v", color, got)
46+ }
47+ })
48+ }
49+}
50+
51+func TestOKLabDistance(t *testing.T) {
52+ a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5})
53+ b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9})
54+ if got := okLabDistance(a, a); got != 0 {
55+ t.Fatalf("self distance = %v, want 0", got)
56+ }
57+ if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 {
58+ t.Fatalf("distance symmetry: %v != %v", got, want)
59+ }
60+}
61+
62+func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) {
63+ background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}
64+ accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
65+
66+ if got := okLabLerp(background, accent, 0); got != background {
67+ t.Fatalf("weight 0 = %v, want %v", got, background)
68+ }
69+ if got := okLabLerp(background, accent, 1); got != accent {
70+ t.Fatalf("weight 1 = %v, want %v", got, accent)
71+ }
72+ if got := okLabLerp(background, accent, -1); got != background {
73+ t.Fatalf("clamped low weight = %v, want %v", got, background)
74+ }
75+ if got := okLabLerp(background, accent, 2); got != accent {
76+ t.Fatalf("clamped high weight = %v, want %v", got, accent)
77+ }
78+
79+ previous := background
80+ for _, weight := range []float64{0.25, 0.5, 0.75, 1} {
81+ got := okLabLerp(background, accent, weight)
82+ if got.R < previous.R || got.G < previous.G || got.B < previous.B {
83+ t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous)
84+ }
85+ previous = got
86+ }
87+}
88+
89+func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
90+ got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5})
91+ if got.R != 255 || got.G != 0 || got.B != 0 {
92+ t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got)
93+ }
94+}
95+
96+func channelDelta(a, b uint8) uint8 {
97+ if a > b {
98+ return a - b
99+ }
100+ return b - a
101+}
A internal/usecase/theme/phase3_review_test.go +171 -0
@@ -0,0 +1,171 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
12+ teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
13+ explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}
14+ tests := []struct {
15+ name string
16+ theme Theme
17+ policy domain.ThemeAccent
18+ want Accent
19+ }{
20+ {
21+ name: "explicit unknown slot decorates only with its exact index",
22+ theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}),
23+ policy: explicit,
24+ want: Accent{Slot: 5, IndexedOnly: true},
25+ },
26+ {
27+ name: "explicit known slot without truecolor is indexed only",
28+ theme: func() Theme {
29+ t := paletteTheme(map[int]renderer.RGB{5: teal})
30+ t.TrueColor = false
31+ return t
32+ }(),
33+ policy: explicit,
34+ want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true},
35+ },
36+ {
37+ name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
38+ theme: Theme{
39+ Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},
40+ HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true,
41+ },
42+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
43+ want: Accent{Slot: 12, IndexedOnly: true},
44+ },
45+ {
46+ name: "automatic absent defaults keeps light scheme indexed blue fallback",
47+ theme: Theme{
48+ TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true,
49+ },
50+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
51+ want: Accent{Slot: 4, IndexedOnly: true},
52+ },
53+ {
54+ name: "automatic absent palette and unknown scheme stays neutral",
55+ theme: Theme{TrueColor: true, Known: true, UsePalette: true},
56+ policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
57+ want: Accent{},
58+ },
59+ {
60+ name: "palette off suppresses explicit indexed decoration",
61+ theme: func() Theme {
62+ t := paletteTheme(map[int]renderer.RGB{5: teal})
63+ t.UsePalette = false
64+ return t
65+ }(),
66+ policy: explicit,
67+ want: Accent{},
68+ },
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy))
74+ })
75+ }
76+}
77+
78+func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) {
79+ theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
80+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5})
81+ require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent)
82+
83+ for name, style := range legacyAndSemanticStyles(resolved.Styles) {
84+ require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name)
85+ }
86+ require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground)
87+ require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground)
88+}
89+
90+func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) {
91+ theme := rampTheme(false)
92+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
93+ theme.Palette[10] = theme.Palette[2]
94+ theme.PaletteKnown = 1<<2 | 1<<10
95+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
96+
97+ want, ok := secondaryText(resolved.Styles.PickerBase.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB)
98+ require.True(t, ok)
99+ require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB)
100+ require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB), normalTextContrast)
101+}
102+
103+func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
104+ paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
105+ paletteOff.UsePalette = false
106+ tests := []struct {
107+ name string
108+ theme Theme
109+ }{
110+ {name: "palette off", theme: paletteOff},
111+ {name: "forced dark", theme: BuiltinDark},
112+ {name: "forced light", theme: BuiltinLight},
113+ }
114+ for _, tt := range tests {
115+ t.Run(tt.name, func(t *testing.T) {
116+ got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
117+ want := prePhase3LegacyAliases(tt.theme)
118+ // PickerSeparator is now a semantic role with the SurfaceBar
119+ // background; it was never a compatibility alias.
120+ delete(want, "PickerSeparator")
121+ for name, style := range want {
122+ require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
123+ }
124+ })
125+ }
126+}
127+
128+func prePhase3LegacyAliases(t Theme) map[string]renderer.Style {
129+ status := renderer.DefaultStyle()
130+ accent := inverseStyle()
131+ border := renderer.DefaultStyle()
132+ muted := renderer.DefaultStyle()
133+ if usable(t) {
134+ status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
135+ accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
136+ border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
137+ muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
138+ }
139+ selection := accent
140+ return map[string]renderer.Style{
141+ "StatusBar": status,
142+ "Accent": accent,
143+ "Border": border,
144+ "Selection": selection,
145+ "CopyStatus": selection,
146+ "PaletteDesc": muted,
147+ "TabName": EmphasisStyle(status, t),
148+ "TabNameActive": EmphasisStyle(accent, t),
149+ "TabTitle": MutedVariantStyle(status, t),
150+ "TabTitleActive": MutedVariantStyle(accent, t),
151+ "PickerName": EmphasisStyle(renderer.DefaultStyle(), t),
152+ "PickerSelectionName": EmphasisStyle(selection, t),
153+ "PickerSelectionMuted": MutedVariantStyle(selection, t),
154+ "PickerSeparator": muted,
155+ }
156+}
157+
158+func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style {
159+ return map[string]renderer.Style{
160+ "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive,
161+ "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive,
162+ "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive,
163+ "NeutralBorder": styles.NeutralBorder, "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar,
164+ "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection,
165+ "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc,
166+ "TabName": styles.TabName, "TabNameActive": styles.TabNameActive,
167+ "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive,
168+ "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName,
169+ "PickerSelectionMuted": styles.PickerSelectionMuted,
170+ }
171+}
A internal/usecase/theme/ramp.go +174 -0
@@ -0,0 +1,174 @@
1+package theme
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+const (
6+ normalTextContrast = 4.5
7+ borderContrast = 3.0
8+)
9+
10+// Ramp is the contrast-safe set of semantic terminal chrome colors.
11+type Ramp struct {
12+ SurfaceBar renderer.Style
13+ SurfaceInactive renderer.Style
14+ SurfaceRecent renderer.Style
15+ SurfaceActive renderer.Style
16+ BorderMuted renderer.Style
17+ BorderActive renderer.Style
18+
19+ background renderer.RGB
20+ accent renderer.RGB
21+ rgb bool
22+}
23+
24+// BuildRamp derives every RGB surface from the terminal background and one
25+// resolved accent. It deliberately never uses indexed colors for backgrounds.
26+func BuildRamp(t Theme, accent Accent) Ramp {
27+ if !accent.Known || accent.IndexedOnly || !usable(t) {
28+ return neutralRamp(t)
29+ }
30+
31+ bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
32+ if !ok {
33+ return neutralRamp(t)
34+ }
35+ inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
36+ if !ok {
37+ return neutralRamp(t)
38+ }
39+ recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
40+ if !ok {
41+ return neutralRamp(t)
42+ }
43+ active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
44+ if !ok {
45+ return neutralRamp(t)
46+ }
47+
48+ return Ramp{
49+ SurfaceBar: bar,
50+ SurfaceInactive: inactive,
51+ SurfaceRecent: recent,
52+ SurfaceActive: active,
53+ BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
54+ BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
55+ background: t.Background,
56+ accent: accent.RGB,
57+ rgb: true,
58+ }
59+}
60+
61+// surfaceAtOrBelow searches from the requested intensity to neutral. The
62+// integer sequence is intentional: it makes contrast adaptation stable.
63+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
64+ for weight := target; weight >= 0; weight-- {
65+ background := okLabLerp(t.Background, accent, float64(weight)/100)
66+ foreground, ok := primaryText(t, background)
67+ if !ok {
68+ continue
69+ }
70+ if _, ok := secondaryText(foreground, background); !ok {
71+ continue
72+ }
73+ return rgbSurface(foreground, background), true
74+ }
75+ return renderer.Style{}, false
76+}
77+
78+func rgbSurface(foreground, background renderer.RGB) renderer.Style {
79+ style := renderer.DefaultStyle()
80+ style.HasForegroundRGB = true
81+ style.ForegroundRGB = foreground
82+ style.HasBackgroundRGB = true
83+ style.BackgroundRGB = background
84+ return style
85+}
86+
87+// primaryText chooses only the reported default foreground or background.
88+func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) {
89+ best := t.Foreground
90+ bestContrast := ContrastRatio(t.Foreground, surface)
91+ backgroundContrast := ContrastRatio(t.Background, surface)
92+ if backgroundContrast > bestContrast {
93+ best, bestContrast = t.Background, backgroundContrast
94+ }
95+ return best, bestContrast >= normalTextContrast
96+}
97+
98+// secondaryText mutes toward the actual surface while retaining normal-text
99+// contrast. Searching down from 40 selects the greatest permitted mute.
100+func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) {
101+ for weight := 40; weight >= 0; weight-- {
102+ candidate := okLabLerp(primary, surface, float64(weight)/100)
103+ if ContrastRatio(candidate, surface) >= normalTextContrast {
104+ return candidate, true
105+ }
106+ }
107+ return renderer.RGB{}, false
108+}
109+
110+// mutedBorder starts at 60 percent and only moves toward the accent, choosing
111+// the lowest weight that separates from its adjacent surface.
112+func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
113+ for weight := 60; weight <= 100; weight++ {
114+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
115+ if ContrastRatio(candidate, adjacent) >= borderContrast {
116+ return foregroundStyle(candidate)
117+ }
118+ }
119+ return neutralBorder(t)
120+}
121+
122+// activeBorder starts at the accent endpoint and reduces only when needed.
123+func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
124+ for weight := 100; weight >= 0; weight-- {
125+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
126+ if ContrastRatio(candidate, adjacent) >= borderContrast {
127+ return foregroundStyle(candidate)
128+ }
129+ }
130+ return neutralBorder(t)
131+}
132+
133+func foregroundStyle(color renderer.RGB) renderer.Style {
134+ style := renderer.DefaultStyle()
135+ style.HasForegroundRGB = true
136+ style.ForegroundRGB = color
137+ return style
138+}
139+
140+func neutralRamp(t Theme) Ramp {
141+ styles := neutralStyles(t)
142+ return Ramp{
143+ SurfaceBar: styles.SurfaceBar,
144+ SurfaceInactive: styles.SurfaceInactive,
145+ SurfaceRecent: styles.SurfaceRecent,
146+ SurfaceActive: styles.SurfaceActive,
147+ BorderMuted: styles.BorderMuted,
148+ BorderActive: styles.BorderActive,
149+ }
150+}
151+
152+// MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for
153+// the oldest entry. A singleton is therefore the full recent surface.
154+func MRUStyle(ramp Ramp, index, count int) renderer.Style {
155+ if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB {
156+ return ramp.SurfaceRecent
157+ }
158+ if index < 0 {
159+ index = 0
160+ }
161+ if index >= count {
162+ index = count - 1
163+ }
164+ if !ramp.rgb {
165+ return ramp.SurfaceRecent
166+ }
167+ weight := 22.0 - 11.0*float64(index)/float64(count-1)
168+ background := okLabLerp(ramp.background, ramp.accent, weight/100)
169+ foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background)
170+ if !ok {
171+ return ramp.SurfaceRecent
172+ }
173+ return rgbSurface(foreground, background)
174+}
A internal/usecase/theme/ramp_test.go +135 -0
@@ -0,0 +1,135 @@
1+package theme
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildRampUsesExactSemanticWeights(t *testing.T) {
12+ theme := rampTheme(false)
13+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
14+ ramp := BuildRamp(theme, accent)
15+
16+ for name, got := range map[string]renderer.Style{
17+ "bar": ramp.SurfaceBar,
18+ "inactive": ramp.SurfaceInactive,
19+ "recent": ramp.SurfaceRecent,
20+ } {
21+ require.True(t, got.HasBackgroundRGB, name)
22+ }
23+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB)
24+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB)
25+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB)
26+ require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB)
27+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB)
28+ require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB)
29+ require.False(t, ramp.BorderMuted.HasBackgroundRGB)
30+ require.False(t, ramp.BorderActive.HasBackgroundRGB)
31+}
32+
33+func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) {
34+ for _, light := range []bool{false, true} {
35+ t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) {
36+ theme := rampTheme(light)
37+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
38+ ramp := BuildRamp(theme, accent)
39+ for name, style := range map[string]renderer.Style{
40+ "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive,
41+ "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive,
42+ } {
43+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name)
44+ }
45+ for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} {
46+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name)
47+ }
48+ })
49+ }
50+}
51+
52+func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
53+ theme := Theme{
54+ Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
55+ Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
56+ HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true,
57+ }
58+ accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
59+ ramp := BuildRamp(theme, accent)
60+
61+ wantWeight := -1
62+ for weight := 100; weight >= 0; weight-- {
63+ surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100)
64+ primary, ok := primaryText(theme, surface)
65+ if !ok {
66+ continue
67+ }
68+ if _, ok := secondaryText(primary, surface); ok {
69+ wantWeight = weight
70+ break
71+ }
72+ }
73+ require.NotEqual(t, 100, wantWeight)
74+ require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
75+}
76+
77+func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
78+ theme := rampTheme(false)
79+ ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
80+ require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))
81+ require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
82+ require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
83+ require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
84+}
85+
86+func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
87+ theme := rampTheme(false)
88+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
89+ theme.Palette[10] = theme.Palette[2]
90+ theme.PaletteKnown = 1<<2 | 1<<10
91+ resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
92+
93+ require.True(t, resolved.Accent.Known)
94+ require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
95+ require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
96+ require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
97+ require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
98+ require.True(t, resolved.Styles.TabActive.Bold)
99+ require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
100+ require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase)
101+ require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
102+ require.True(t, resolved.Styles.PickerSelection.Bold)
103+}
104+
105+func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
106+ theme := rampTheme(false)
107+ theme.TrueColor = false
108+ theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
109+ theme.PaletteKnown = 1 << 2
110+ indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
111+ require.True(t, indexed.Accent.IndexedOnly)
112+ require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
113+ require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
114+ require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
115+
116+ theme.TrueColor = true
117+ theme.UsePalette = false
118+ off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
119+ require.False(t, off.Accent.Known)
120+ require.Equal(t, neutralStyles(theme), off.Styles)
121+
122+ insufficient := rampTheme(false)
123+ insufficient.Foreground = insufficient.Background
124+ insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
125+ insufficient.PaletteKnown = 1 << 2
126+ neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
127+ require.Equal(t, neutralStyles(insufficient), neutral.Styles)
128+}
129+
130+func rampTheme(light bool) Theme {
131+ if light {
132+ return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
133+ }
134+ return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
135+}
A internal/usecase/theme/resolved_styles.go +201 -0
@@ -0,0 +1,201 @@
1+package theme
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+const neutralAccentBlend = 0.25
9+
10+// ResolvedTheme is the immutable, complete chrome style snapshot produced from
11+// a terminal theme and typed accent policy.
12+type ResolvedTheme struct {
13+ Theme Theme
14+ Accent Accent
15+ Ramp Ramp
16+ Styles Styles
17+}
18+
19+// Resolve is the single entry point for terminal-owned accent inference and
20+// semantic chrome construction.
21+func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme {
22+ accent := ResolveAccent(t, policy)
23+ ramp := BuildRamp(t, accent)
24+ styles := stylesFromRamp(t, accent, ramp)
25+ return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles}
26+}
27+
28+func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
29+ if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb {
30+ styles := neutralStyles(t)
31+ if accent.IndexedOnly {
32+ styles = indexedStyles(styles, accent.Slot)
33+ }
34+ return styles
35+ }
36+
37+ inactiveTitle := secondarySurface(ramp.SurfaceInactive)
38+ activeTitle := secondarySurface(ramp.SurfaceActive)
39+ inactiveDescription := secondarySurface(ramp.SurfaceInactive)
40+ barDescription := secondarySurface(ramp.SurfaceBar)
41+ styles := Styles{
42+ SurfaceBar: ramp.SurfaceBar,
43+ SurfaceInactive: ramp.SurfaceInactive,
44+ SurfaceRecent: ramp.SurfaceRecent,
45+ SurfaceActive: ramp.SurfaceActive,
46+ BorderMuted: ramp.BorderMuted,
47+ BorderActive: ramp.BorderActive,
48+ NeutralBorder: neutralBorder(t),
49+
50+ TabInactive: ramp.SurfaceInactive,
51+ TabInactiveTitle: inactiveTitle,
52+ TabActive: EmphasisStyle(ramp.SurfaceActive, t),
53+ TabActiveTitle: activeTitle,
54+ MRURecent: ramp.SurfaceRecent,
55+
56+ PickerBase: ramp.SurfaceBar,
57+ PickerSelection: EmphasisStyle(ramp.SurfaceActive, t),
58+ PickerDescription: inactiveDescription,
59+ // Separators are secondary text on the picker base surface, not
60+ // non-text borders; they therefore require the normal 4.5:1 contrast.
61+ PickerSeparator: barDescription,
62+ PromptBase: ramp.SurfaceBar,
63+ CopyStatus: ramp.SurfaceBar,
64+ SearchSelection: EmphasisStyle(ramp.SurfaceActive, t),
65+ }
66+ return withMRUStyles(withLegacyAliases(styles, t), ramp)
67+}
68+
69+func secondarySurface(base renderer.Style) renderer.Style {
70+ if !base.HasForegroundRGB || !base.HasBackgroundRGB {
71+ return base
72+ }
73+ foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB)
74+ if !ok {
75+ return base
76+ }
77+ base.ForegroundRGB = foreground
78+ return base
79+}
80+
81+func indexedStyles(styles Styles, slot uint8) Styles {
82+ styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
83+ styles.BorderActive = indexedForeground(styles.BorderActive, slot)
84+ styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
85+ styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
86+ styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
87+ styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot)
88+
89+ // Compatibility aliases historically received the scheme-aware indexed
90+ // blue fallback too. Keep the strict explicit index on each foreground
91+ // decoration without changing any neutral surface background.
92+ styles.Border = styles.BorderMuted
93+ styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot)
94+ styles.TabTitle = styles.TabInactiveTitle
95+ styles.TabTitleActive = styles.TabActiveTitle
96+ styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot)
97+ return styles
98+}
99+
100+func indexedForeground(style renderer.Style, slot uint8) renderer.Style {
101+ style.HasForegroundRGB = false
102+ style.Foreground = int(slot)
103+ style.Inverse = false
104+ return style
105+}
106+
107+// neutralStyles deliberately preserves the pre-accent neutral hierarchy. It
108+// is used for palette-off, forced themes, incomplete defaults, and RGB-less
109+// indexed fallbacks; it never performs a palette slot lookup.
110+func neutralStyles(t Theme) Styles {
111+ status := renderer.DefaultStyle()
112+ accent := inverseStyle()
113+ border := renderer.DefaultStyle()
114+ inactiveDescription := status
115+ barDescription := status
116+ if usable(t) {
117+ status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
118+ accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
119+ border = neutralBorder(t)
120+ // Preserve the legacy neutral muted foreground while retaining the
121+ // semantic row/bar background beneath description and separator cells.
122+ inactiveDescription = status
123+ inactiveDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
124+ barDescription = status
125+ barDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
126+ }
127+ styles := Styles{
128+ SurfaceBar: status,
129+ SurfaceInactive: status,
130+ SurfaceRecent: status,
131+ SurfaceActive: accent,
132+ BorderMuted: border,
133+ BorderActive: border,
134+ NeutralBorder: border,
135+
136+ TabInactive: EmphasisStyle(status, t),
137+ TabInactiveTitle: MutedVariantStyle(status, t),
138+ TabActive: EmphasisStyle(accent, t),
139+ TabActiveTitle: MutedVariantStyle(accent, t),
140+ MRURecent: status,
141+
142+ PickerBase: status,
143+ PickerSelection: EmphasisStyle(accent, t),
144+ PickerDescription: inactiveDescription,
145+ PickerSeparator: barDescription,
146+ PromptBase: status,
147+ CopyStatus: accent,
148+ SearchSelection: accent,
149+ }
150+ styles = withLegacyAliases(styles, t)
151+ // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy
152+ // foreground-only bytes; rendered palette descriptions use PickerDescription.
153+ styles.PaletteDesc = legacyMutedText(t)
154+ // Neutral and indexed fallbacks retain their existing non-RGB hierarchy;
155+ // MRU entries deliberately share the neutral recent surface.
156+ return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent})
157+}
158+
159+func withMRUStyles(styles Styles, ramp Ramp) Styles {
160+ for count := 1; count <= len(styles.mruStyles); count++ {
161+ for index := 0; index < count; index++ {
162+ styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count)
163+ }
164+ }
165+ return styles
166+}
167+
168+// neutralBorder is the existing non-accent structural border input for pane
169+// chrome. It intentionally does not consume a resolved accent and is called
170+// only while resolving an immutable style snapshot.
171+func neutralBorder(t Theme) renderer.Style {
172+ if !usable(t) {
173+ return renderer.DefaultStyle()
174+ }
175+ return foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
176+}
177+
178+func legacyMutedText(t Theme) renderer.Style {
179+ if !usable(t) {
180+ return renderer.DefaultStyle()
181+ }
182+ return foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
183+}
184+
185+func withLegacyAliases(styles Styles, t Theme) Styles {
186+ styles.StatusBar = styles.SurfaceBar
187+ styles.Accent = styles.SurfaceActive
188+ styles.Border = styles.BorderMuted
189+ styles.Selection = styles.SurfaceActive
190+ styles.PaletteDesc = styles.PickerDescription
191+ styles.TabName = styles.TabInactive
192+ styles.TabNameActive = styles.TabActive
193+ styles.TabTitle = styles.TabInactiveTitle
194+ styles.TabTitleActive = styles.TabActiveTitle
195+ // These aliases retain their pre-semantic-role contracts for render paths
196+ // that have not migrated yet.
197+ styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t)
198+ styles.PickerSelectionName = EmphasisStyle(styles.Selection, t)
199+ styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t)
200+ return styles
201+}
M internal/usecase/theme/styles.go +57 -32
@@ -3,6 +3,7 @@ package theme
33 import (
44 "math"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/pkg/renderer"
78 )
89
@@ -58,47 +59,71 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
5859 return base
5960 }
6061
61-// Styles is the resolved set of terminal chrome styles for a client theme.
62+// Styles is the complete semantic set of terminal chrome styles. The legacy
63+// aliases remain until daemon renderers consume the semantic fields directly.
6264 type Styles struct {
63- StatusBar renderer.Style
64- Accent renderer.Style
65- Border renderer.Style
66- Selection renderer.Style
67- CopyStatus renderer.Style
68- PaletteDesc renderer.Style
65+ // mruStyles are all position/count combinations that status composition
66+ // can display. They keep OKLab interpolation out of render paths.
67+ mruStyles [9][9]renderer.Style
6968
70- TabName renderer.Style
71- TabNameActive renderer.Style
72- TabTitle renderer.Style
73- TabTitleActive renderer.Style
69+ SurfaceBar renderer.Style
70+ SurfaceInactive renderer.Style
71+ SurfaceRecent renderer.Style
72+ SurfaceActive renderer.Style
73+ BorderMuted renderer.Style
74+ BorderActive renderer.Style
75+ // NeutralBorder is the non-accent structural border for pane dividers and
76+ // unfocused pane title bars. It is resolved with the rest of the immutable
77+ // chrome snapshot so composition never derives theme colors.
78+ NeutralBorder renderer.Style
7479
80+ TabInactive renderer.Style
81+ TabInactiveTitle renderer.Style
82+ TabActive renderer.Style
83+ TabActiveTitle renderer.Style
84+ MRURecent renderer.Style
85+
86+ PickerBase renderer.Style
87+ PickerSelection renderer.Style
88+ PickerDescription renderer.Style
89+ PickerSeparator renderer.Style
90+ PromptBase renderer.Style
91+ CopyStatus renderer.Style
92+ SearchSelection renderer.Style
93+
94+ // Compatibility aliases for pre-cache render paths.
95+ StatusBar renderer.Style
96+ Accent renderer.Style
97+ Border renderer.Style
98+ Selection renderer.Style
99+ PaletteDesc renderer.Style
100+ TabName renderer.Style
101+ TabNameActive renderer.Style
102+ TabTitle renderer.Style
103+ TabTitleActive renderer.Style
75104 PickerName renderer.Style
76105 PickerSelectionName renderer.Style
77106 PickerSelectionMuted renderer.Style
78- PickerSeparator renderer.Style
79107 }
80108
81-// NewStyles composes the terminal chrome styles from the semantic theme styles.
109+// NewStyles applies the default automatic policy. New callers that own a
110+// policy should use Resolve and retain its complete immutable result.
82111 func NewStyles(t Theme) Styles {
83- statusBar := StatusBarStyle(t)
84- accent := AccentStyle(t)
85- selection := SelectionStyle(t)
86- return Styles{
87- StatusBar: statusBar,
88- Accent: accent,
89- Border: BorderStyle(t),
90- Selection: selection,
91- CopyStatus: selection,
92- PaletteDesc: MutedTextStyle(t),
93-
94- TabName: EmphasisStyle(statusBar, t),
95- TabNameActive: EmphasisStyle(accent, t),
96- TabTitle: PaletteMutedVariantStyle(statusBar, t),
97- TabTitleActive: PaletteMutedVariantStyle(accent, t),
112+ return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
113+}
98114
99- PickerName: EmphasisStyle(renderer.DefaultStyle(), t),
100- PickerSelectionName: EmphasisStyle(selection, t),
101- PickerSelectionMuted: PaletteMutedVariantStyle(selection, t),
102- PickerSeparator: MutedTextStyle(t),
115+// MRUStyle returns the position-dependent recent-session surface. Color
116+// interpolation stays owned by theme; renderers consume only this cached
117+// semantic style snapshot.
118+func (s Styles) MRUStyle(index, count int) renderer.Style {
119+ if count <= 0 || count > len(s.mruStyles) {
120+ return s.MRURecent
121+ }
122+ if index < 0 {
123+ index = 0
124+ }
125+ if index >= count {
126+ index = count - 1
103127 }
128+ return s.mruStyles[count-1][index]
104129 }
M internal/usecase/theme/styles_test.go +15 -323
@@ -139,6 +139,21 @@ func TestPulseColor(t *testing.T) {
139139 }
140140 }
141141
142+func TestIndexedForegroundClearsInverseForNonTrueColorFallback(t *testing.T) {
143+ fallback := indexedForeground(renderer.Style{
144+ Bold: true,
145+ Inverse: true,
146+ Foreground: -1,
147+ HasForegroundRGB: true,
148+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
149+ }, 2)
150+
151+ require.False(t, fallback.Inverse, "an indexed foreground must not become an effective background")
152+ require.False(t, fallback.HasForegroundRGB)
153+ require.Equal(t, 2, fallback.Foreground)
154+ require.True(t, fallback.Bold)
155+}
156+
142157 func TestMRUFade(t *testing.T) {
143158 base := renderer.Style{
144159 Bold: true,
@@ -254,326 +269,3 @@ func TestThemeIsComparable(t *testing.T) {
254269 themes := map[Theme]bool{BuiltinDark: true}
255270 require.True(t, themes[BuiltinDark])
256271 }
257-
258-func TestPaletteAccentBackground(t *testing.T) {
259- base := Theme{
260- Foreground: renderer.RGB{R: 200, G: 200, B: 200},
261- Background: renderer.RGB{R: 100, G: 100, B: 100},
262- HasFG: true,
263- HasBG: true,
264- Known: true,
265- TrueColor: true,
266- UsePalette: true,
267- }
268- neutral := Blend(base.Background, base.Foreground, accentBlend)
269-
270- tests := []struct {
271- name string
272- palette [16]renderer.RGB
273- known uint16
274- usePalette bool
275- want renderer.RGB
276- wantContrastMin bool
277- }{
278- {
279- name: "slot 4 supplies a contrasting accent background",
280- palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}},
281- known: 1<<4 | 1<<12,
282- usePalette: true,
283- want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend),
284- wantContrastMin: true,
285- },
286- {
287- name: "slot 4 failure escalates to slot 12",
288- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}},
289- known: 1<<4 | 1<<12,
290- usePalette: true,
291- want: Blend(base.Background, renderer.RGB{}, accentBlend),
292- wantContrastMin: true,
293- },
294- {
295- name: "both palette blues failing uses exact neutral blend",
296- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
297- known: 1<<4 | 1<<12,
298- usePalette: true,
299- want: neutral,
300- },
301- {
302- name: "missing palette blues uses exact neutral blend",
303- usePalette: true,
304- want: neutral,
305- },
306- {
307- name: "palette disabled preserves neutral accent",
308- palette: [16]renderer.RGB{4: {B: 255}},
309- known: 1 << 4,
310- usePalette: false,
311- want: neutral,
312- },
313- }
314-
315- for _, tt := range tests {
316- t.Run(tt.name, func(t *testing.T) {
317- theme := base
318- theme.Palette = tt.palette
319- theme.PaletteKnown = tt.known
320- theme.UsePalette = tt.usePalette
321-
322- got := AccentStyle(theme)
323- require.True(t, got.HasBackgroundRGB)
324- require.Equal(t, tt.want, got.BackgroundRGB)
325- if tt.wantContrastMin {
326- require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin)
327- }
328- })
329- }
330-}
331-
332-func TestPaletteTitleHueDerivation(t *testing.T) {
333- base := Theme{
334- Foreground: renderer.RGB{R: 200, G: 200, B: 200},
335- Background: renderer.RGB{R: 100, G: 100, B: 100},
336- HasFG: true,
337- HasBG: true,
338- Known: true,
339- TrueColor: true,
340- UsePalette: true,
341- }
342-
343- tests := []struct {
344- name string
345- theme Theme
346- palette [16]renderer.RGB
347- known uint16
348- }{
349- {
350- name: "slot 4 is used when it contrasts for each title",
351- theme: Theme{
352- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
353- Background: renderer.RGB{R: 50, G: 50, B: 50},
354- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
355- },
356- palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}},
357-
358- known: 1<<4 | 1<<12,
359- },
360- {
361- name: "each title escalates independently from slot 4 to slot 12",
362- palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}},
363- known: 1<<4 | 1<<12,
364- },
365- {
366- name: "both title hues failing retains neutral muted styles",
367- palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}},
368- known: 1<<4 | 1<<12,
369- },
370- }
371-
372- for _, tt := range tests {
373- t.Run(tt.name, func(t *testing.T) {
374- theme := base
375- if tt.theme.Known {
376- theme = tt.theme
377- }
378- theme.Palette = tt.palette
379- theme.PaletteKnown = tt.known
380- styles := NewStyles(theme)
381- status := StatusBarStyle(theme)
382- accent := AccentStyle(theme)
383-
384- if tt.name == "both title hues failing retains neutral muted styles" {
385- require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme)))
386- require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
387- return
388- }
389-
390- require.True(t, styles.TabTitle.HasForegroundRGB)
391- require.True(t, styles.TabTitleActive.HasForegroundRGB)
392- require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin)
393- require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin)
394-
395- if tt.name == "slot 4 is used when it contrasts for each title" {
396- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB)
397- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
398- }
399- if tt.name == "each title escalates independently from slot 4 to slot 12" {
400- require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB)
401- require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB)
402- }
403- })
404- }
405-}
406-
407-func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) {
408- base := Theme{
409- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
410- Background: renderer.RGB{R: 50, G: 50, B: 50},
411- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
412- }
413-
414- tests := []struct {
415- name string
416- theme Theme
417- wantForeground renderer.RGB
418- wantIndexed int
419- }{
420- {
421- name: "RGB palette foreground",
422- theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(),
423- wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend),
424- wantIndexed: -1,
425- },
426- {
427- name: "indexed foreground fallback",
428- theme: func() Theme { t := base; t.SchemeKnown = true; return t }(),
429- wantIndexed: ansiBrightBlue,
430- },
431- }
432-
433- for _, tt := range tests {
434- t.Run(tt.name, func(t *testing.T) {
435- styles := NewStyles(tt.theme)
436-
437- for name, style := range map[string]renderer.Style{
438- "border": styles.Border,
439- "muted text": styles.PaletteDesc,
440- } {
441- require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name)
442- if tt.wantIndexed >= 0 {
443- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
444- require.Equal(t, tt.wantIndexed, style.Foreground, name)
445- } else {
446- require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
447- require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
448- }
449- }
450-
451- status := StatusBarStyle(tt.theme)
452- accent := AccentStyle(tt.theme)
453- for name, style := range map[string]renderer.Style{
454- "title": styles.TabTitle,
455- "active title": styles.TabTitleActive,
456- } {
457- wantBackground := status.BackgroundRGB
458- if name == "active title" {
459- wantBackground = accent.BackgroundRGB
460- }
461- require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name)
462- require.Equal(t, wantBackground, style.BackgroundRGB, name)
463- if tt.wantIndexed >= 0 {
464- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
465- require.Equal(t, tt.wantIndexed, style.Foreground, name)
466- } else {
467- require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name)
468- require.Equal(t, tt.wantForeground, style.ForegroundRGB, name)
469- }
470- }
471- })
472- }
473-}
474-
475-func TestPaletteIndexedForegroundFallback(t *testing.T) {
476- base := Theme{UsePalette: true, SchemeKnown: true, Known: true}
477-
478- tests := []struct {
479- name string
480- theme Theme
481- want int
482- ok bool
483- }{
484- {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true},
485- {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true},
486- {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}},
487- {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}},
488- }
489-
490- for _, tt := range tests {
491- t.Run(tt.name, func(t *testing.T) {
492- styles := NewStyles(tt.theme)
493- baselineTheme := tt.theme
494- baselineTheme.UsePalette = false
495- baseline := NewStyles(baselineTheme)
496- for name, style := range map[string]renderer.Style{
497- "title": styles.TabTitle,
498- "active title": styles.TabTitleActive,
499- "border": styles.Border,
500- "muted text": styles.PaletteDesc,
501- } {
502- require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name)
503- if tt.ok {
504- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
505- require.Equal(t, tt.want, style.Foreground, name)
506- } else {
507- want := map[string]renderer.Style{
508- "title": baseline.TabTitle, "active title": baseline.TabTitleActive,
509- "border": baseline.Border, "muted text": baseline.PaletteDesc,
510- }[name]
511- require.Equal(t, want, style, name)
512- }
513- }
514- })
515- }
516-}
517-
518-func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) {
519- theme := Theme{
520- Foreground: renderer.RGB{R: 240, G: 240, B: 240},
521- Background: renderer.RGB{R: 30, G: 30, B: 30},
522- HasFG: true, HasBG: true, Known: true, TrueColor: true,
523- UsePalette: true, SchemeKnown: true,
524- }
525- styles := NewStyles(theme)
526- for name, style := range map[string]renderer.Style{
527- "title": styles.TabTitle, "active title": styles.TabTitleActive,
528- "border": styles.Border, "muted text": styles.PaletteDesc,
529- } {
530- require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name)
531- require.Equal(t, ansiBrightBlue, style.Foreground, name)
532- require.Equal(t, -1, style.Background, "%s must not use an indexed background", name)
533- }
534-}
535-
536-func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) {
537- palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}}
538- usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
539- withDisabledPalette := usableTheme
540- withDisabledPalette.Palette = palette
541- withDisabledPalette.PaletteKnown = 1<<4 | 1<<12
542- withDisabledPalette.UsePalette = false
543-
544- require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette))
545-
546- forced := Theme{Known: true, SchemeKnown: true, UsePalette: false}
547- require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced))
548-}
549-
550-func TestNewStylesComposesSemanticStyles(t *testing.T) {
551- theme := Theme{
552- Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8},
553- Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e},
554- HasFG: true,
555- HasBG: true,
556- Known: true,
557- TrueColor: true,
558- }
559-
560- styles := NewStyles(theme)
561- statusBar := StatusBarStyle(theme)
562- accent := AccentStyle(theme)
563- selection := SelectionStyle(theme)
564-
565- require.True(t, styles.StatusBar.Equal(statusBar))
566- require.True(t, styles.Accent.Equal(accent))
567- require.True(t, styles.Border.Equal(BorderStyle(theme)))
568- require.True(t, styles.Selection.Equal(selection))
569- require.True(t, styles.CopyStatus.Equal(selection))
570- require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme)))
571- require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme)))
572- require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme)))
573- require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme)))
574- require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme)))
575- require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme)))
576- require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme)))
577- require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme)))
578- require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme)))
579-}
M internal/usecase/theme/theme.go +10 -135
@@ -356,89 +356,22 @@ func StatusBarStyle(t Theme) renderer.Style {
356356 return style
357357 }
358358
359-const (
360- ansiBlue = 4
361- ansiBrightBlue = 12
362- accentBlend = 0.25
363- titleHueBlend = 0.2
364-)
365-
359+// The compatibility helpers below retain the neutral styles for isolated
360+// callers. Accent-aware construction goes exclusively through Resolve.
366361 func AccentStyle(t Theme) renderer.Style {
367- if !usable(t) {
368- return inverseStyle()
369- }
370- style := renderer.DefaultStyle()
371- style.HasForegroundRGB = true
372- style.ForegroundRGB = t.Foreground
373- style.HasBackgroundRGB = true
374- style.BackgroundRGB = accentBackground(t)
375- return style
376-}
377-
378-// accentBackground uses a terminal's blue palette only when the resulting
379-// foreground/background pair remains legible. A neutral blend is always the
380-// fallback, including when palette inheritance is disabled.
381-func accentBackground(t Theme) renderer.RGB {
382- neutral := Blend(t.Background, t.Foreground, accentBlend)
383- if !t.UsePalette {
384- return neutral
385- }
386- for _, slot := range []int{ansiBlue, ansiBrightBlue} {
387- blue, ok := t.PaletteColor(slot)
388- if !ok {
389- continue
390- }
391- candidate := Blend(t.Background, blue, accentBlend)
392- if ContrastRatio(t.Foreground, candidate) >= accentContrastMin {
393- return candidate
394- }
395- }
396- return neutral
362+ return neutralStyles(t).SurfaceActive
397363 }
398364
399365 func BorderStyle(t Theme) renderer.Style {
400- neutral := renderer.DefaultStyle()
401- if usable(t) {
402- neutral.HasForegroundRGB = true
403- neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
404- }
405- if !t.UsePalette {
406- return neutral
407- }
408-
409- base := renderer.DefaultStyle()
410- if usable(t) {
411- base.HasForegroundRGB = true
412- base.ForegroundRGB = t.Foreground
413- base.HasBackgroundRGB = true
414- base.BackgroundRGB = t.Background
415- }
416- return paletteForegroundStyle(base, neutral, t)
366+ return neutralStyles(t).BorderMuted
417367 }
418368
419369 func MutedTextStyle(t Theme) renderer.Style {
420- neutral := renderer.DefaultStyle()
421- if usable(t) {
422- neutral.HasForegroundRGB = true
423- neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45)
424- }
425- if !t.UsePalette {
426- return neutral
427- }
428-
429- base := renderer.DefaultStyle()
430- if usable(t) {
431- base.HasForegroundRGB = true
432- base.ForegroundRGB = t.Foreground
433- base.HasBackgroundRGB = true
434- base.BackgroundRGB = t.Background
435- }
436- return paletteForegroundStyle(base, neutral, t)
370+ return neutralStyles(t).PickerDescription
437371 }
438372
439373 // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
440-// foreground toward its own background, subtler than MutedTextStyle's 0.45
441-// (which blends the theme's global fg/bg instead of a style's own colors).
374+// foreground toward its own background.
442375 const mutedVariantBlend = 0.4
443376
444377 // EmphasisStyle returns base with bold applied when the client theme supports
@@ -452,81 +385,23 @@ func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
452385 return out
453386 }
454387
455-// MutedVariantStyle fades base's foreground toward its background when both
456-// are RGB and the client theme supports styled output; otherwise base is
457-// returned unchanged. When base has an RGB foreground but no RGB background,
458-// it fades toward t.Background instead.
388+// MutedVariantStyle fades base's foreground toward its own background.
459389 func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
460390 if !usable(t) || !base.HasForegroundRGB {
461391 return base
462392 }
463- bg := base.BackgroundRGB
393+ background := base.BackgroundRGB
464394 if !base.HasBackgroundRGB {
465395 if !t.HasBG {
466396 return base
467397 }
468- bg = t.Background
398+ background = t.Background
469399 }
470400 out := base
471- out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend)
401+ out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend)
472402 return out
473403 }
474404
475-// PaletteMutedVariantStyle derives a title foreground from a terminal blue
476-// palette entry. Its contrast is evaluated against the title's actual base
477-// background, rather than the terminal background, because status and active
478-// title backgrounds differ.
479-func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
480- return paletteForegroundStyle(base, MutedVariantStyle(base, t), t)
481-}
482-
483-// paletteForegroundStyle uses palette blues for a foreground only. It never
484-// changes a background, so palette inheritance cannot introduce indexed
485-// backgrounds. If terminal RGB data is unavailable, a known color scheme can
486-// still select a best-effort ANSI foreground. A reported blue that fails
487-// contrast deliberately suppresses that indexed fallback.
488-func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style {
489- if !t.UsePalette {
490- return neutral
491- }
492-
493- knownBlue := paletteBlueKnown(t)
494- if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB {
495- for _, slot := range []int{ansiBlue, ansiBrightBlue} {
496- blue, ok := t.PaletteColor(slot)
497- if !ok {
498- continue
499- }
500- candidate := Blend(base.ForegroundRGB, blue, titleHueBlend)
501- if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin {
502- out := neutral
503- out.HasForegroundRGB = true
504- out.ForegroundRGB = candidate
505- return out
506- }
507- }
508- }
509-
510- if !knownBlue && t.SchemeKnown {
511- out := neutral
512- out.HasForegroundRGB = false
513- if t.Light {
514- out.Foreground = ansiBlue
515- } else {
516- out.Foreground = ansiBrightBlue
517- }
518- return out
519- }
520- return neutral
521-}
522-
523-func paletteBlueKnown(t Theme) bool {
524- if !t.UsePalette {
525- return false
526- }
527- return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0
528-}
529-
530405 const defaultDimmingPercent = 35
531406
532407 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
M internal/usecase/ui/modal.go +6 -4
@@ -48,9 +48,11 @@ func (m Modal) Inner(base domain.Size) domain.Rect {
4848 }
4949 }
5050
51-// Composite draws the modal border and title, clears the interior, and returns
52-// the inner content rectangle. Cells outside the modal bounds are not changed.
53-func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
51+// Composite draws the modal border and title, fills its interior, and returns
52+// the inner content rectangle. Border and interior styles are deliberately
53+// independent so unfocused structure and chrome surfaces retain their roles.
54+// Cells outside the modal bounds are not changed.
55+func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
5456 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
5557 inner := domain.Rect{
5658 X: bounds.X + 1,
@@ -59,7 +61,7 @@ func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
5961 Height: max(0, bounds.Height-2),
6062 }
6163 DrawBox(f, bounds, border)
62- FillRect(f, inner, renderer.BlankCell())
64+ FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
6365 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
6466 left := bounds.X + 1
6567 right := bounds.X + bounds.Width - 1
M internal/usecase/ui/modal_test.go +34 -4
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
89 )
910
1011 func TestModalBounds(t *testing.T) {
@@ -130,9 +131,10 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
130131 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
131132 FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
132133
133- style := renderer.Style{Bold: true, Foreground: 2, Background: -1}
134+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135+ interior := renderer.Style{Foreground: 3, Background: 4}
134136 m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
135- inner := m.Composite(f, style)
137+ inner := m.Composite(f, border, interior)
136138 wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
137139 if inner != wantInner {
138140 t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
@@ -149,11 +151,15 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
149151
150152 for y := inner.Y; y < inner.Y+inner.Height; y++ {
151153 for x := inner.X; x < inner.X+inner.Width; x++ {
152- if got := f.At(x, y); !got.Equal(renderer.BlankCell()) {
153- t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got)
154+ want := renderer.Cell{Rune: ' ', Style: interior}
155+ if got := f.At(x, y); !got.Equal(want) {
156+ t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want)
154157 }
155158 }
156159 }
160+ if got := f.At(2, 1).Style; !got.Equal(border) {
161+ t.Fatalf("border style = %+v, want %+v", got, border)
162+ }
157163 assertCell(t, f, 0, 0, exterior)
158164 assertCell(t, f, 9, 5, exterior)
159165 }
@@ -214,3 +220,27 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
214220 t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
215221 }
216222 }
223+
224+func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {
225+ border := renderer.Style{Foreground: 1, Background: 2}
226+ interior := renderer.Style{Foreground: 3, Background: 4}
227+ for _, tt := range []struct {
228+ name string
229+ size domain.Size
230+ want domain.Rect
231+ }{
232+ {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},
233+ {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},
234+ } {
235+ t.Run(tt.name, func(t *testing.T) {
236+ frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)
237+ got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)
238+ require.Equal(t, tt.want, got)
239+ for y := range frame.Height {
240+ for x := range frame.Width {
241+ require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
242+ }
243+ }
244+ })
245+ }
246+}
M internal/usecase/visualsearch/model.go +16 -4
@@ -133,6 +133,12 @@ func (m *Model) clamp() {
133133
134134 const defaultVisibleRows = 10
135135
136+// RenderStyles contains the base chrome surface and active selection role.
137+type RenderStyles struct {
138+ Base renderer.Style
139+ Selection renderer.Style
140+}
141+
136142 func (m *Model) ensureVisible(visible int) {
137143 if m == nil || visible <= 0 || len(m.matches) == 0 {
138144 return
@@ -146,16 +152,22 @@ func (m *Model) ensureVisible(visible int) {
146152 }
147153
148154 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
149- frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
150- if frame.Width == 0 || frame.Height == 0 {
151- return frame
152- }
153155 base := renderer.DefaultStyle()
154156 selection := base
155157 selection.Inverse = true
156158 if len(selectedStyle) > 0 {
157159 selection = selectedStyle[0]
158160 }
161+ return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection})
162+}
163+
164+// RenderStyled renders with explicit chrome roles captured by the daemon.
165+func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame {
166+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
167+ if frame.Width == 0 || frame.Height == 0 {
168+ return frame
169+ }
170+ base, selection := styles.Base, styles.Selection
159171 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
160172 ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection)
161173 visible := frame.Height - 1
M internal/usecase/visualsearch/model_test.go +21 -0
@@ -186,3 +186,24 @@ func rowText(row []renderer.Cell) string {
186186 }
187187 return b.String()
188188 }
189+
190+func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) {
191+ base := renderer.Style{Foreground: 1, Background: 2}
192+ selection := renderer.Style{Foreground: 3, Background: 4}
193+ for _, tt := range []struct {
194+ name string
195+ x, y int
196+ want renderer.Style
197+ why string
198+ }{
199+ {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"},
200+ {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"},
201+ } {
202+ t.Run(tt.name, func(t *testing.T) {
203+ m := New(testSnapshot("alpha"))
204+ m.Insert('a')
205+ frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection})
206+ require.True(t, frame.At(tt.x, tt.y).Style.Equal(tt.want), tt.why)
207+ })
208+ }
209+}
M transport_replay_integration_test.go +77 -0
@@ -1,11 +1,13 @@
11 package main_test
22
33 import (
4+ "io"
45 "net"
56 "sync"
67 "testing"
78
89 "github.com/bnema/vev/internal/adapters/ipc"
10+ "github.com/bnema/vev/internal/adapters/sshstdio"
911 "github.com/bnema/vev/internal/ports"
1012 "github.com/bnema/vev/internal/testutil/replaytest"
1113 "github.com/bnema/vev/pkg/renderer"
@@ -46,6 +48,81 @@ func TestTransportReplayIntegration(t *testing.T) {
4648 require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame))
4749 }
4850
51+// TestThemeGenerationTransportSequences proves the generation's cleared then
52+// definitive wire sequence is unchanged across the local socket and remote
53+// SSH-stdio transports. The transports only carry frames; neither can infer
54+// or alter the palette lifecycle.
55+func TestThemeGenerationTransportSequences(t *testing.T) {
56+ cleared := ports.Theme{
57+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
58+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
59+ TrueColor: true, SchemeKnown: true,
60+ }
61+ palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}}
62+ definitive := cleared
63+ definitive.Palette = palette
64+ definitive.PaletteKnown = 1<<2 | 1<<10
65+ sequence := []ports.Theme{cleared, definitive}
66+
67+ tests := []struct {
68+ name string
69+ pair func(*testing.T) (ports.Transport, ports.Transport)
70+ }{
71+ {
72+ name: "local ipc socket",
73+ pair: func(t *testing.T) (ports.Transport, ports.Transport) {
74+ left, right := net.Pipe()
75+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
76+ return ipc.NewTransport(left), ipc.NewTransport(right)
77+ },
78+ },
79+ {
80+ name: "remote ssh stdio",
81+ pair: func(t *testing.T) (ports.Transport, ports.Transport) {
82+ clientRead, serverWrite := io.Pipe()
83+ serverRead, clientWrite := io.Pipe()
84+ closeClient := func() error {
85+ _ = clientRead.Close()
86+ return clientWrite.Close()
87+ }
88+ closeServer := func() error {
89+ _ = serverRead.Close()
90+ return serverWrite.Close()
91+ }
92+ t.Cleanup(func() { _ = closeClient(); _ = closeServer() })
93+ return sshstdio.NewTransport(clientRead, clientWrite, closeClient), sshstdio.NewTransport(serverRead, serverWrite, closeServer)
94+ },
95+ },
96+ }
97+
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ sender, receiver := tt.pair(t)
101+ done := make(chan error, 1)
102+ go func() {
103+ for _, snapshot := range sequence {
104+ if err := sender.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}); err != nil {
105+ done <- err
106+ return
107+ }
108+ }
109+ done <- nil
110+ }()
111+
112+ for _, want := range sequence {
113+ frame, err := receiver.Recv()
114+ require.NoError(t, err)
115+ require.Equal(t, ports.MsgTheme, frame.Type)
116+ require.Equal(t, ports.MarshalTheme(want), frame.Payload, "theme payload must be preserved byte-for-byte before decode")
117+ got, err := ports.UnmarshalTheme(frame.Payload)
118+ require.NoError(t, err)
119+ require.Equal(t, want, got)
120+ }
121+ require.NoError(t, <-done)
122+ })
123+ }
124+}
125+
49126 func integrationFrameRows(frame renderer.Frame) []string {
50127 rows := make([]string, frame.Height)
51128 for y := range rows {

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