fix(daemon): re-negotiate color profile when an attachment resumes
A resumed attachment kept the color mode and renderer profile from the parked link, so a reconnect with a different Hello.Color encoded output for the wrong terminal. Apply the latest Hello color, swap the renderer profile, and re-resolve the chrome theme. Cover per-attachment profiles across full, incremental, reset and resume paints.
- commit
- 1a70faf88f3e97964d6461ee37bcbf2ae082d776
- author
- brice · · 2026-10-03 13:34 UTC
- committer
- brice · · 2026-10-03 14:14 UTC
- parent
- 46e9f79
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +285 -0
A internal/usecase/daemon/attachment_color_profile_test.go +258 -0
| @@ -0,0 +1,258 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var ( | |
| 14 | + | colorTrue = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared} | |
| 15 | + | color16 = terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared} | |
| 16 | + | colorMono = terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared} | |
| 17 | + | ||
| 18 | + | // paneColorWrites exercise every color channel a pane can carry: RGB | |
| 19 | + | // foreground/background, indexed 16-255 foreground/background, and RGB and | |
| 20 | + | // indexed underline color. | |
| 21 | + | paneColorWrites = []string{ | |
| 22 | + | "\x1b[38;2;10;200;30mR\x1b[0m", | |
| 23 | + | "\x1b[48;2;200;10;30mB\x1b[0m", | |
| 24 | + | "\x1b[38;5;196mF\x1b[0m", | |
| 25 | + | "\x1b[48;5;21mG\x1b[0m", | |
| 26 | + | "\x1b[4;58;2;0;255;0mU\x1b[0m", | |
| 27 | + | "\x1b[4;58;5;200mV\x1b[0m", | |
| 28 | + | } | |
| 29 | + | extendedColor = regexp.MustCompile(`^(38|48|58)`) | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // colorClient is one attachment plus the output frames its transport received. | |
| 33 | + | type colorClient struct { | |
| 34 | + | ac *attachedClient | |
| 35 | + | tr *closeTrackingTransport | |
| 36 | + | seen int | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // newOutput returns the Output messages sent since the previous call and their | |
| 40 | + | // concatenated terminal bytes. | |
| 41 | + | func (c *colorClient) newOutput(t *testing.T) ([]protocol.Output, []byte) { | |
| 42 | + | t.Helper() | |
| 43 | + | frames := testFramesOfType(c.tr.Sends(), "Output") | |
| 44 | + | var ( | |
| 45 | + | outs []protocol.Output | |
| 46 | + | data []byte | |
| 47 | + | ) | |
| 48 | + | for _, frame := range frames[c.seen:] { | |
| 49 | + | out := unmarshalTestOutput(t, frame.Payload) | |
| 50 | + | outs = append(outs, out) | |
| 51 | + | data = append(data, out.Data...) | |
| 52 | + | } | |
| 53 | + | c.seen = len(frames) | |
| 54 | + | return outs, data | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func colorHello(intent uint8, name string, client byte, token uint64, color terminalcap.ColorCapabilities) protocol.Hello { | |
| 58 | + | h := helloResumeCapable(intent, name, token) | |
| 59 | + | h.ClientID = [16]byte{client} | |
| 60 | + | h.Color = color | |
| 61 | + | return h | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // requireProfile asserts data is encoded for color: pane RGB/indexed channels | |
| 65 | + | // survive only where the mode can express them. | |
| 66 | + | func requireProfile(t *testing.T, mode terminalcap.ColorMode, data []byte) { | |
| 67 | + | t.Helper() | |
| 68 | + | params := colorParams(data) | |
| 69 | + | switch mode { | |
| 70 | + | case terminalcap.TrueColor: | |
| 71 | + | require.Contains(t, params, "38;2;10;200;30", "%q", data) | |
| 72 | + | require.Contains(t, params, "48;2;200;10;30", "%q", data) | |
| 73 | + | require.Contains(t, params, "58;2;0;255;0", "%q", data) | |
| 74 | + | require.Contains(t, params, "38;5;196", "%q", data) | |
| 75 | + | require.Contains(t, params, "48;5;21", "%q", data) | |
| 76 | + | require.Contains(t, params, "58;5;200", "%q", data) | |
| 77 | + | case terminalcap.ANSI16: | |
| 78 | + | require.NotEmpty(t, params, "ansi16 keeps basic colors: %q", data) | |
| 79 | + | for _, p := range params { | |
| 80 | + | require.NotRegexp(t, extendedColor, p, "ansi16 output carries extended color: %q", data) | |
| 81 | + | } | |
| 82 | + | case terminalcap.Monochrome: | |
| 83 | + | require.Empty(t, params, "monochrome output carries color: %q", data) | |
| 84 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)[;:]`, string(data)) | |
| 85 | + | default: | |
| 86 | + | t.Fatalf("unhandled mode %v", mode) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func attachColorClients(t *testing.T, modes []terminalcap.ColorCapabilities) (*Daemon, *session, []*colorClient) { | |
| 91 | + | t.Helper() | |
| 92 | + | pty, release := newBlockingPTY(t) | |
| 93 | + | t.Cleanup(release) | |
| 94 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 95 | + | var ( | |
| 96 | + | sess *session | |
| 97 | + | clients []*colorClient | |
| 98 | + | ) | |
| 99 | + | for i, color := range modes { | |
| 100 | + | tr := &closeTrackingTransport{} | |
| 101 | + | intent := protocol.IntentAttach | |
| 102 | + | if i == 0 { | |
| 103 | + | intent = protocol.IntentNew | |
| 104 | + | } | |
| 105 | + | s, ac, err := d.route(colorHello(intent, "work", byte(i+1), 0, color), tr) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | if sess == nil { | |
| 108 | + | sess = s | |
| 109 | + | } | |
| 110 | + | require.Same(t, sess, s) | |
| 111 | + | require.Equal(t, color.Mode, ac.terminalCapabilities.Color.Mode) | |
| 112 | + | clients = append(clients, &colorClient{ac: ac, tr: tr}) | |
| 113 | + | } | |
| 114 | + | return d, sess, clients | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // TestAttachmentsEncodePaneOutputWithOwnColorProfile covers pane output for | |
| 118 | + | // attachments of one session that negotiated different color modes: full | |
| 119 | + | // first frame, incremental update, and explicit renderer reset. | |
| 120 | + | func TestAttachmentsEncodePaneOutputWithOwnColorProfile(t *testing.T) { | |
| 121 | + | modes := []terminalcap.ColorCapabilities{colorTrue, color16, colorMono} | |
| 122 | + | d, sess, clients := attachColorClients(t, modes) | |
| 123 | + | pane := sess.tabs[0].focusedPane() | |
| 124 | + | for _, w := range paneColorWrites { | |
| 125 | + | pane.screen.Write([]byte(w)) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | paintAll := func(reset bool) { | |
| 129 | + | for _, c := range clients { | |
| 130 | + | d.paint(sess, c.ac, reset, nil) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | // Each step repaints every attachment and then checks that attachment's | |
| 134 | + | // bytes against its own mode. | |
| 135 | + | steps := []struct { | |
| 136 | + | name string | |
| 137 | + | setup func() | |
| 138 | + | reset bool | |
| 139 | + | full bool | |
| 140 | + | }{ | |
| 141 | + | {name: "initial full frame", reset: true, full: true}, | |
| 142 | + | {name: "incremental draw", setup: func() { | |
| 143 | + | for _, w := range paneColorWrites { | |
| 144 | + | pane.screen.Write([]byte("\r\n" + w)) | |
| 145 | + | } | |
| 146 | + | }}, | |
| 147 | + | {name: "renderer reset", reset: true, full: true}, | |
| 148 | + | {name: "incremental after reset", setup: func() { | |
| 149 | + | for _, w := range paneColorWrites { | |
| 150 | + | pane.screen.Write([]byte("\r\n" + w)) | |
| 151 | + | } | |
| 152 | + | }}, | |
| 153 | + | } | |
| 154 | + | var prevNew [3]uint64 | |
| 155 | + | for _, step := range steps { | |
| 156 | + | if step.setup != nil { | |
| 157 | + | step.setup() | |
| 158 | + | } | |
| 159 | + | paintAll(step.reset) | |
| 160 | + | for i, c := range clients { | |
| 161 | + | t.Run(step.name+"/"+modeName(modes[i].Mode), func(t *testing.T) { | |
| 162 | + | outs, data := c.newOutput(t) | |
| 163 | + | require.NotEmpty(t, outs) | |
| 164 | + | first := outs[0] | |
| 165 | + | if step.full { | |
| 166 | + | require.Zero(t, first.Base, "full frames start a new chain") | |
| 167 | + | } else { | |
| 168 | + | require.Equal(t, prevNew[i], first.Base, "incremental frames extend the chain") | |
| 169 | + | } | |
| 170 | + | prevNew[i] = outs[len(outs)-1].New | |
| 171 | + | c.ac.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |
| 172 | + | requireProfile(t, modes[i].Mode, data) | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func modeName(m terminalcap.ColorMode) string { | |
| 179 | + | switch m { | |
| 180 | + | case terminalcap.TrueColor: | |
| 181 | + | return "truecolor" | |
| 182 | + | case terminalcap.ANSI16: | |
| 183 | + | return "ansi16" | |
| 184 | + | case terminalcap.Monochrome: | |
| 185 | + | return "monochrome" | |
| 186 | + | default: | |
| 187 | + | return "other" | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // TestResumeAdoptsColorOfLatestHello proves a resumed attachment encodes for | |
| 192 | + | // the replacement terminal's Hello.Color, not the one it parked with. | |
| 193 | + | func TestResumeAdoptsColorOfLatestHello(t *testing.T) { | |
| 194 | + | tests := []struct { | |
| 195 | + | name string | |
| 196 | + | from, to terminalcap.ColorCapabilities | |
| 197 | + | liveResume bool // resume while the old link is still registered | |
| 198 | + | }{ | |
| 199 | + | {name: "truecolor to monochrome", from: colorTrue, to: colorMono}, | |
| 200 | + | {name: "truecolor to ansi16", from: colorTrue, to: color16}, | |
| 201 | + | {name: "monochrome to truecolor", from: colorMono, to: colorTrue}, | |
| 202 | + | {name: "ansi16 to truecolor", from: color16, to: colorTrue}, | |
| 203 | + | {name: "ansi16 to monochrome", from: color16, to: colorMono}, | |
| 204 | + | {name: "same mode", from: color16, to: color16}, | |
| 205 | + | {name: "live resume truecolor to monochrome", from: colorTrue, to: colorMono, liveResume: true}, | |
| 206 | + | {name: "live resume monochrome to ansi16", from: colorMono, to: color16, liveResume: true}, | |
| 207 | + | } | |
| 208 | + | for _, tt := range tests { | |
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |
| 210 | + | pty, release := newBlockingPTY(t) | |
| 211 | + | defer release() | |
| 212 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 213 | + | oldTr := &closeTrackingTransport{} | |
| 214 | + | sess, ac, err := d.route(colorHello(protocol.IntentNew, "work", 1, 0, tt.from), oldTr) | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | for _, w := range paneColorWrites { | |
| 217 | + | sess.tabs[0].focusedPane().screen.Write([]byte(w)) | |
| 218 | + | } | |
| 219 | + | before := &colorClient{ac: ac, tr: oldTr} | |
| 220 | + | d.paint(sess, ac, true, nil) | |
| 221 | + | _, data := before.newOutput(t) | |
| 222 | + | requireProfile(t, tt.from.Mode, data) | |
| 223 | + | ||
| 224 | + | token := ac.resumeToken | |
| 225 | + | if !tt.liveResume { | |
| 226 | + | d.clientGone(sess, ac, oldTr, false) | |
| 227 | + | } | |
| 228 | + | newTr := &closeTrackingTransport{} | |
| 229 | + | resumedSess, resumed, err := d.route(colorHello(protocol.IntentResume, "work", 1, token, tt.to), newTr) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | require.Same(t, sess, resumedSess) | |
| 232 | + | require.Same(t, ac, resumed) | |
| 233 | + | require.Equal(t, tt.to, resumed.terminalCapabilities.Color) | |
| 234 | + | ||
| 235 | + | after := &colorClient{ac: resumed, tr: newTr} | |
| 236 | + | d.paint(resumedSess, resumed, true, nil) | |
| 237 | + | outs, data := after.newOutput(t) | |
| 238 | + | require.NotEmpty(t, outs) | |
| 239 | + | require.Zero(t, outs[0].Base, "resume starts with a full frame") | |
| 240 | + | requireProfile(t, tt.to.Mode, data) | |
| 241 | + | resumed.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |
| 242 | + | ||
| 243 | + | // The profile also holds for incremental updates after resume. | |
| 244 | + | for _, w := range paneColorWrites { | |
| 245 | + | sess.tabs[0].focusedPane().screen.Write([]byte("\r\n" + w)) | |
| 246 | + | } | |
| 247 | + | d.paint(resumedSess, resumed, false, nil) | |
| 248 | + | outs, data = after.newOutput(t) | |
| 249 | + | require.NotEmpty(t, outs) | |
| 250 | + | require.NotZero(t, outs[0].Base) | |
| 251 | + | requireProfile(t, tt.to.Mode, data) | |
| 252 | + | ||
| 253 | + | // Chrome styling follows the new mode as well. | |
| 254 | + | reduced := tt.to.Mode != terminalcap.TrueColor | |
| 255 | + | require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute) | |
| 256 | + | }) | |
| 257 | + | } | |
| 258 | + | } | |
| @@ -0,0 +1,258 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var ( | |||
| 14 | + | colorTrue = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared} | |||
| 15 | + | color16 = terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared} | |||
| 16 | + | colorMono = terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared} | |||
| 17 | + | ||||
| 18 | + | // paneColorWrites exercise every color channel a pane can carry: RGB | |||
| 19 | + | // foreground/background, indexed 16-255 foreground/background, and RGB and | |||
| 20 | + | // indexed underline color. | |||
| 21 | + | paneColorWrites = []string{ | |||
| 22 | + | "\x1b[38;2;10;200;30mR\x1b[0m", | |||
| 23 | + | "\x1b[48;2;200;10;30mB\x1b[0m", | |||
| 24 | + | "\x1b[38;5;196mF\x1b[0m", | |||
| 25 | + | "\x1b[48;5;21mG\x1b[0m", | |||
| 26 | + | "\x1b[4;58;2;0;255;0mU\x1b[0m", | |||
| 27 | + | "\x1b[4;58;5;200mV\x1b[0m", | |||
| 28 | + | } | |||
| 29 | + | extendedColor = regexp.MustCompile(`^(38|48|58)`) | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // colorClient is one attachment plus the output frames its transport received. | |||
| 33 | + | type colorClient struct { | |||
| 34 | + | ac *attachedClient | |||
| 35 | + | tr *closeTrackingTransport | |||
| 36 | + | seen int | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // newOutput returns the Output messages sent since the previous call and their | |||
| 40 | + | // concatenated terminal bytes. | |||
| 41 | + | func (c *colorClient) newOutput(t *testing.T) ([]protocol.Output, []byte) { | |||
| 42 | + | t.Helper() | |||
| 43 | + | frames := testFramesOfType(c.tr.Sends(), "Output") | |||
| 44 | + | var ( | |||
| 45 | + | outs []protocol.Output | |||
| 46 | + | data []byte | |||
| 47 | + | ) | |||
| 48 | + | for _, frame := range frames[c.seen:] { | |||
| 49 | + | out := unmarshalTestOutput(t, frame.Payload) | |||
| 50 | + | outs = append(outs, out) | |||
| 51 | + | data = append(data, out.Data...) | |||
| 52 | + | } | |||
| 53 | + | c.seen = len(frames) | |||
| 54 | + | return outs, data | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func colorHello(intent uint8, name string, client byte, token uint64, color terminalcap.ColorCapabilities) protocol.Hello { | |||
| 58 | + | h := helloResumeCapable(intent, name, token) | |||
| 59 | + | h.ClientID = [16]byte{client} | |||
| 60 | + | h.Color = color | |||
| 61 | + | return h | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // requireProfile asserts data is encoded for color: pane RGB/indexed channels | |||
| 65 | + | // survive only where the mode can express them. | |||
| 66 | + | func requireProfile(t *testing.T, mode terminalcap.ColorMode, data []byte) { | |||
| 67 | + | t.Helper() | |||
| 68 | + | params := colorParams(data) | |||
| 69 | + | switch mode { | |||
| 70 | + | case terminalcap.TrueColor: | |||
| 71 | + | require.Contains(t, params, "38;2;10;200;30", "%q", data) | |||
| 72 | + | require.Contains(t, params, "48;2;200;10;30", "%q", data) | |||
| 73 | + | require.Contains(t, params, "58;2;0;255;0", "%q", data) | |||
| 74 | + | require.Contains(t, params, "38;5;196", "%q", data) | |||
| 75 | + | require.Contains(t, params, "48;5;21", "%q", data) | |||
| 76 | + | require.Contains(t, params, "58;5;200", "%q", data) | |||
| 77 | + | case terminalcap.ANSI16: | |||
| 78 | + | require.NotEmpty(t, params, "ansi16 keeps basic colors: %q", data) | |||
| 79 | + | for _, p := range params { | |||
| 80 | + | require.NotRegexp(t, extendedColor, p, "ansi16 output carries extended color: %q", data) | |||
| 81 | + | } | |||
| 82 | + | case terminalcap.Monochrome: | |||
| 83 | + | require.Empty(t, params, "monochrome output carries color: %q", data) | |||
| 84 | + | require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)[;:]`, string(data)) | |||
| 85 | + | default: | |||
| 86 | + | t.Fatalf("unhandled mode %v", mode) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func attachColorClients(t *testing.T, modes []terminalcap.ColorCapabilities) (*Daemon, *session, []*colorClient) { | |||
| 91 | + | t.Helper() | |||
| 92 | + | pty, release := newBlockingPTY(t) | |||
| 93 | + | t.Cleanup(release) | |||
| 94 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 95 | + | var ( | |||
| 96 | + | sess *session | |||
| 97 | + | clients []*colorClient | |||
| 98 | + | ) | |||
| 99 | + | for i, color := range modes { | |||
| 100 | + | tr := &closeTrackingTransport{} | |||
| 101 | + | intent := protocol.IntentAttach | |||
| 102 | + | if i == 0 { | |||
| 103 | + | intent = protocol.IntentNew | |||
| 104 | + | } | |||
| 105 | + | s, ac, err := d.route(colorHello(intent, "work", byte(i+1), 0, color), tr) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | if sess == nil { | |||
| 108 | + | sess = s | |||
| 109 | + | } | |||
| 110 | + | require.Same(t, sess, s) | |||
| 111 | + | require.Equal(t, color.Mode, ac.terminalCapabilities.Color.Mode) | |||
| 112 | + | clients = append(clients, &colorClient{ac: ac, tr: tr}) | |||
| 113 | + | } | |||
| 114 | + | return d, sess, clients | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestAttachmentsEncodePaneOutputWithOwnColorProfile covers pane output for | |||
| 118 | + | // attachments of one session that negotiated different color modes: full | |||
| 119 | + | // first frame, incremental update, and explicit renderer reset. | |||
| 120 | + | func TestAttachmentsEncodePaneOutputWithOwnColorProfile(t *testing.T) { | |||
| 121 | + | modes := []terminalcap.ColorCapabilities{colorTrue, color16, colorMono} | |||
| 122 | + | d, sess, clients := attachColorClients(t, modes) | |||
| 123 | + | pane := sess.tabs[0].focusedPane() | |||
| 124 | + | for _, w := range paneColorWrites { | |||
| 125 | + | pane.screen.Write([]byte(w)) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | paintAll := func(reset bool) { | |||
| 129 | + | for _, c := range clients { | |||
| 130 | + | d.paint(sess, c.ac, reset, nil) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | // Each step repaints every attachment and then checks that attachment's | |||
| 134 | + | // bytes against its own mode. | |||
| 135 | + | steps := []struct { | |||
| 136 | + | name string | |||
| 137 | + | setup func() | |||
| 138 | + | reset bool | |||
| 139 | + | full bool | |||
| 140 | + | }{ | |||
| 141 | + | {name: "initial full frame", reset: true, full: true}, | |||
| 142 | + | {name: "incremental draw", setup: func() { | |||
| 143 | + | for _, w := range paneColorWrites { | |||
| 144 | + | pane.screen.Write([]byte("\r\n" + w)) | |||
| 145 | + | } | |||
| 146 | + | }}, | |||
| 147 | + | {name: "renderer reset", reset: true, full: true}, | |||
| 148 | + | {name: "incremental after reset", setup: func() { | |||
| 149 | + | for _, w := range paneColorWrites { | |||
| 150 | + | pane.screen.Write([]byte("\r\n" + w)) | |||
| 151 | + | } | |||
| 152 | + | }}, | |||
| 153 | + | } | |||
| 154 | + | var prevNew [3]uint64 | |||
| 155 | + | for _, step := range steps { | |||
| 156 | + | if step.setup != nil { | |||
| 157 | + | step.setup() | |||
| 158 | + | } | |||
| 159 | + | paintAll(step.reset) | |||
| 160 | + | for i, c := range clients { | |||
| 161 | + | t.Run(step.name+"/"+modeName(modes[i].Mode), func(t *testing.T) { | |||
| 162 | + | outs, data := c.newOutput(t) | |||
| 163 | + | require.NotEmpty(t, outs) | |||
| 164 | + | first := outs[0] | |||
| 165 | + | if step.full { | |||
| 166 | + | require.Zero(t, first.Base, "full frames start a new chain") | |||
| 167 | + | } else { | |||
| 168 | + | require.Equal(t, prevNew[i], first.Base, "incremental frames extend the chain") | |||
| 169 | + | } | |||
| 170 | + | prevNew[i] = outs[len(outs)-1].New | |||
| 171 | + | c.ac.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |||
| 172 | + | requireProfile(t, modes[i].Mode, data) | |||
| 173 | + | }) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func modeName(m terminalcap.ColorMode) string { | |||
| 179 | + | switch m { | |||
| 180 | + | case terminalcap.TrueColor: | |||
| 181 | + | return "truecolor" | |||
| 182 | + | case terminalcap.ANSI16: | |||
| 183 | + | return "ansi16" | |||
| 184 | + | case terminalcap.Monochrome: | |||
| 185 | + | return "monochrome" | |||
| 186 | + | default: | |||
| 187 | + | return "other" | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // TestResumeAdoptsColorOfLatestHello proves a resumed attachment encodes for | |||
| 192 | + | // the replacement terminal's Hello.Color, not the one it parked with. | |||
| 193 | + | func TestResumeAdoptsColorOfLatestHello(t *testing.T) { | |||
| 194 | + | tests := []struct { | |||
| 195 | + | name string | |||
| 196 | + | from, to terminalcap.ColorCapabilities | |||
| 197 | + | liveResume bool // resume while the old link is still registered | |||
| 198 | + | }{ | |||
| 199 | + | {name: "truecolor to monochrome", from: colorTrue, to: colorMono}, | |||
| 200 | + | {name: "truecolor to ansi16", from: colorTrue, to: color16}, | |||
| 201 | + | {name: "monochrome to truecolor", from: colorMono, to: colorTrue}, | |||
| 202 | + | {name: "ansi16 to truecolor", from: color16, to: colorTrue}, | |||
| 203 | + | {name: "ansi16 to monochrome", from: color16, to: colorMono}, | |||
| 204 | + | {name: "same mode", from: color16, to: color16}, | |||
| 205 | + | {name: "live resume truecolor to monochrome", from: colorTrue, to: colorMono, liveResume: true}, | |||
| 206 | + | {name: "live resume monochrome to ansi16", from: colorMono, to: color16, liveResume: true}, | |||
| 207 | + | } | |||
| 208 | + | for _, tt := range tests { | |||
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 210 | + | pty, release := newBlockingPTY(t) | |||
| 211 | + | defer release() | |||
| 212 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 213 | + | oldTr := &closeTrackingTransport{} | |||
| 214 | + | sess, ac, err := d.route(colorHello(protocol.IntentNew, "work", 1, 0, tt.from), oldTr) | |||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | for _, w := range paneColorWrites { | |||
| 217 | + | sess.tabs[0].focusedPane().screen.Write([]byte(w)) | |||
| 218 | + | } | |||
| 219 | + | before := &colorClient{ac: ac, tr: oldTr} | |||
| 220 | + | d.paint(sess, ac, true, nil) | |||
| 221 | + | _, data := before.newOutput(t) | |||
| 222 | + | requireProfile(t, tt.from.Mode, data) | |||
| 223 | + | ||||
| 224 | + | token := ac.resumeToken | |||
| 225 | + | if !tt.liveResume { | |||
| 226 | + | d.clientGone(sess, ac, oldTr, false) | |||
| 227 | + | } | |||
| 228 | + | newTr := &closeTrackingTransport{} | |||
| 229 | + | resumedSess, resumed, err := d.route(colorHello(protocol.IntentResume, "work", 1, token, tt.to), newTr) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | require.Same(t, sess, resumedSess) | |||
| 232 | + | require.Same(t, ac, resumed) | |||
| 233 | + | require.Equal(t, tt.to, resumed.terminalCapabilities.Color) | |||
| 234 | + | ||||
| 235 | + | after := &colorClient{ac: resumed, tr: newTr} | |||
| 236 | + | d.paint(resumedSess, resumed, true, nil) | |||
| 237 | + | outs, data := after.newOutput(t) | |||
| 238 | + | require.NotEmpty(t, outs) | |||
| 239 | + | require.Zero(t, outs[0].Base, "resume starts with a full frame") | |||
| 240 | + | requireProfile(t, tt.to.Mode, data) | |||
| 241 | + | resumed.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New) | |||
| 242 | + | ||||
| 243 | + | // The profile also holds for incremental updates after resume. | |||
| 244 | + | for _, w := range paneColorWrites { | |||
| 245 | + | sess.tabs[0].focusedPane().screen.Write([]byte("\r\n" + w)) | |||
| 246 | + | } | |||
| 247 | + | d.paint(resumedSess, resumed, false, nil) | |||
| 248 | + | outs, data = after.newOutput(t) | |||
| 249 | + | require.NotEmpty(t, outs) | |||
| 250 | + | require.NotZero(t, outs[0].Base) | |||
| 251 | + | requireProfile(t, tt.to.Mode, data) | |||
| 252 | + | ||||
| 253 | + | // Chrome styling follows the new mode as well. | |||
| 254 | + | reduced := tt.to.Mode != terminalcap.TrueColor | |||
| 255 | + | require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute) | |||
| 256 | + | }) | |||
| 257 | + | } | |||
| 258 | + | } | |||
M internal/usecase/daemon/attachment_output.go +19 -0
| @@ -50,6 +50,12 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||
| 50 | 50 | return | |
| 51 | 51 | } | |
| 52 | 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | |
| 53 | + | // The replacement terminal's color claim wins over whatever the previous | |
| 54 | + | // link negotiated: its renderer must encode for the terminal now attached. | |
| 55 | + | if color := terminalcap.Resolve(h.Env, h.Color).Color; color != ac.terminalCapabilities.Color { | |
| 56 | + | ac.terminalCapabilities.Color = color | |
| 57 | + | ac.output.setColorProfile(colorprofile.Profile(color)) | |
| 58 | + | } | |
| 53 | 59 | // A replacement terminal declares its own keyboard protocol. | |
| 54 | 60 | if ac.keys != nil { | |
| 55 | 61 | ac.keys.SetKittyKeyboard(h.KittyKeyboard) | |
| @@ -72,6 +78,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||
| 72 | 78 | ac.terminalCapabilities.KittyGraphics = false | |
| 73 | 79 | } | |
| 74 | 80 | ||
| 81 | + | // setColorProfile swaps in a renderer for profile. The next frame is a full | |
| 82 | + | // repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses | |
| 83 | + | // the profile change. | |
| 84 | + | func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) { | |
| 85 | + | if s == nil { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | s.lockView() | |
| 89 | + | defer s.unlockView() | |
| 90 | + | s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile) | |
| 91 | + | s.rebaseLocked() | |
| 92 | + | } | |
| 93 | + | ||
| 75 | 94 | // lockView serializes an attachment's view publication with every output | |
| 76 | 95 | // state transition. It deliberately does not take sendMu: callers that hold | |
| 77 | 96 | // architecture locks can publish a view without reversing the paint path's | |
| @@ -50,6 +50,12 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||||
| 50 | return | 50 | return | ||
| 51 | } | 51 | } | ||
| 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | ||
| 53 | + | // The replacement terminal's color claim wins over whatever the previous | |||
| 54 | + | // link negotiated: its renderer must encode for the terminal now attached. | |||
| 55 | + | if color := terminalcap.Resolve(h.Env, h.Color).Color; color != ac.terminalCapabilities.Color { | |||
| 56 | + | ac.terminalCapabilities.Color = color | |||
| 57 | + | ac.output.setColorProfile(colorprofile.Profile(color)) | |||
| 58 | + | } | |||
| 53 | // A replacement terminal declares its own keyboard protocol. | 59 | // A replacement terminal declares its own keyboard protocol. | ||
| 54 | if ac.keys != nil { | 60 | if ac.keys != nil { | ||
| 55 | ac.keys.SetKittyKeyboard(h.KittyKeyboard) | 61 | ac.keys.SetKittyKeyboard(h.KittyKeyboard) | ||
| @@ -72,6 +78,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, | |||||
| 72 | ac.terminalCapabilities.KittyGraphics = false | 78 | ac.terminalCapabilities.KittyGraphics = false | ||
| 73 | } | 79 | } | ||
| 74 | 80 | ||||
| 81 | + | // setColorProfile swaps in a renderer for profile. The next frame is a full | |||
| 82 | + | // repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses | |||
| 83 | + | // the profile change. | |||
| 84 | + | func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) { | |||
| 85 | + | if s == nil { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | s.lockView() | |||
| 89 | + | defer s.unlockView() | |||
| 90 | + | s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile) | |||
| 91 | + | s.rebaseLocked() | |||
| 92 | + | } | |||
| 93 | + | ||||
| 75 | // lockView serializes an attachment's view publication with every output | 94 | // lockView serializes an attachment's view publication with every output | ||
| 76 | // state transition. It deliberately does not take sendMu: callers that hold | 95 | // state transition. It deliberately does not take sendMu: callers that hold | ||
| 77 | // architecture locks can publish a view without reversing the paint path's | 96 | // architecture locks can publish a view without reversing the paint path's | ||
M internal/usecase/daemon/resume.go +8 -0
| @@ -564,7 +564,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||
| 564 | 564 | ac.sendMu.Unlock() | |
| 565 | 565 | return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} | |
| 566 | 566 | } | |
| 567 | + | colorBefore := ac.terminalCapabilities.Color | |
| 567 | 568 | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | |
| 569 | + | colorChanged := resumed != nil && resumed.terminalCapabilities.Color != colorBefore | |
| 568 | 570 | d.mu.Unlock() | |
| 569 | 571 | ac.sendMu.Unlock() | |
| 570 | 572 | if err == nil && ok && sess != nil && resumed != nil { | |
| @@ -572,6 +574,12 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||
| 572 | 574 | // complete the claiming terminal's geometry before the resumed | |
| 573 | 575 | // attachment is allowed to produce its first frame. | |
| 574 | 576 | sess.geometry.reconcile(d, sess, resumed) | |
| 577 | + | if colorChanged { | |
| 578 | + | // The applied theme (chrome styles) was resolved for the previous | |
| 579 | + | // terminal's color mode; re-resolve it for the new one before the | |
| 580 | + | // first paint. | |
| 581 | + | d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false) | |
| 582 | + | } | |
| 575 | 583 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | |
| 576 | 584 | // the restored scene at this boundary so suppression is explained once. | |
| 577 | 585 | d.warnUnsupportedGraphics(resumed) | |
| @@ -564,7 +564,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||||
| 564 | ac.sendMu.Unlock() | 564 | ac.sendMu.Unlock() | ||
| 565 | return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} | 565 | return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 566 | } | 566 | } | ||
| 567 | + | colorBefore := ac.terminalCapabilities.Color | |||
| 567 | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | 568 | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | ||
| 569 | + | colorChanged := resumed != nil && resumed.terminalCapabilities.Color != colorBefore | |||
| 568 | d.mu.Unlock() | 570 | d.mu.Unlock() | ||
| 569 | ac.sendMu.Unlock() | 571 | ac.sendMu.Unlock() | ||
| 570 | if err == nil && ok && sess != nil && resumed != nil { | 572 | if err == nil && ok && sess != nil && resumed != nil { | ||
| @@ -572,6 +574,12 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do | |||||
| 572 | // complete the claiming terminal's geometry before the resumed | 574 | // complete the claiming terminal's geometry before the resumed | ||
| 573 | // attachment is allowed to produce its first frame. | 575 | // attachment is allowed to produce its first frame. | ||
| 574 | sess.geometry.reconcile(d, sess, resumed) | 576 | sess.geometry.reconcile(d, sess, resumed) | ||
| 577 | + | if colorChanged { | |||
| 578 | + | // The applied theme (chrome styles) was resolved for the previous | |||
| 579 | + | // terminal's color mode; re-resolve it for the new one before the | |||
| 580 | + | // first paint. | |||
| 581 | + | d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false) | |||
| 582 | + | } | |||
| 575 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | 583 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | ||
| 576 | // the restored scene at this boundary so suppression is explained once. | 584 | // the restored scene at this boundary so suppression is explained once. | ||
| 577 | d.warnUnsupportedGraphics(resumed) | 585 | d.warnUnsupportedGraphics(resumed) | ||