detached 1a70faf

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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,
5050 return
5151 }
5252 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+ }
5359 // A replacement terminal declares its own keyboard protocol.
5460 if ac.keys != nil {
5561 ac.keys.SetKittyKeyboard(h.KittyKeyboard)
@@ -72,6 +78,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
7278 ac.terminalCapabilities.KittyGraphics = false
7379 }
7480
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+
7594 // lockView serializes an attachment's view publication with every output
7695 // state transition. It deliberately does not take sendMu: callers that hold
7796 // 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 return50 return
51 }51 }
52 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics52 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 = false78 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 output94 // lockView serializes an attachment's view publication with every output
76 // state transition. It deliberately does not take sendMu: callers that hold95 // state transition. It deliberately does not take sendMu: callers that hold
77 // architecture locks can publish a view without reversing the paint path's96 // 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
564564 ac.sendMu.Unlock()
565565 return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
566566 }
567+ colorBefore := ac.terminalCapabilities.Color
567568 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
569+ colorChanged := resumed != nil && resumed.terminalCapabilities.Color != colorBefore
568570 d.mu.Unlock()
569571 ac.sendMu.Unlock()
570572 if err == nil && ok && sess != nil && resumed != nil {
@@ -572,6 +574,12 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
572574 // complete the claiming terminal's geometry before the resumed
573575 // attachment is allowed to produce its first frame.
574576 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+ }
575583 // A resumed unsupported attachment bypasses finishAttachedClient. Check
576584 // the restored scene at this boundary so suppression is explained once.
577585 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 resumed574 // 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. Check583 // 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)

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