detached 30ad963

fix(daemon): publish color downgrade notice on resume and swap renderer only on mode change

commit
30ad963840b0ba7f11f8db267049d0028fb3f39c
author
brice · · 2026-10-03 14:02 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
a6e1848
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +51 -15
M internal/usecase/daemon/attachment_color_profile_test.go +22 -0
@@ -195,9 +195,22 @@ func TestResumeAdoptsColorOfLatestHello(t *testing.T) {
195195 name string
196196 from, to terminalcap.ColorCapabilities
197197 liveResume bool // resume while the old link is still registered
198+ // wantToasts is checked only when checkToasts is set; it lists the
199+ // visible toast messages after resume.
200+ checkToasts bool
201+ wantToasts []string
198202 }{
199203 {name: "truecolor to monochrome", from: colorTrue, to: colorMono},
200204 {name: "truecolor to ansi16", from: colorTrue, to: color16},
205+ {
206+ name: "declared ansi16 resume shows downgrade notice", from: colorTrue, to: color16,
207+ checkToasts: true, wantToasts: []string{"Terminal supports 16 colors; vev UI colors are reduced."},
208+ },
209+ {
210+ name: "forced monochrome resume shows no notice", from: colorTrue,
211+ to: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced},
212+ checkToasts: true,
213+ },
201214 {name: "monochrome to truecolor", from: colorMono, to: colorTrue},
202215 {name: "ansi16 to truecolor", from: color16, to: colorTrue},
203216 {name: "ansi16 to monochrome", from: color16, to: colorMono},
@@ -250,6 +263,15 @@ func TestResumeAdoptsColorOfLatestHello(t *testing.T) {
250263 require.NotZero(t, outs[0].Base)
251264 requireProfile(t, tt.to.Mode, data)
252265
266+ if tt.checkToasts {
267+ toasts, _ := visibleToasts(resumed)
268+ var got []string
269+ for _, toast := range toasts {
270+ got = append(got, toast.Message)
271+ }
272+ require.Equal(t, tt.wantToasts, got)
273+ }
274+
253275 // Chrome styling follows the new mode as well.
254276 reduced := tt.to.Mode != terminalcap.TrueColor
255277 require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute)
@@ -195,9 +195,22 @@ func TestResumeAdoptsColorOfLatestHello(t *testing.T) {
195 name string195 name string
196 from, to terminalcap.ColorCapabilities196 from, to terminalcap.ColorCapabilities
197 liveResume bool // resume while the old link is still registered197 liveResume bool // resume while the old link is still registered
198+ // wantToasts is checked only when checkToasts is set; it lists the
199+ // visible toast messages after resume.
200+ checkToasts bool
201+ wantToasts []string
198 }{202 }{
199 {name: "truecolor to monochrome", from: colorTrue, to: colorMono},203 {name: "truecolor to monochrome", from: colorTrue, to: colorMono},
200 {name: "truecolor to ansi16", from: colorTrue, to: color16},204 {name: "truecolor to ansi16", from: colorTrue, to: color16},
205+ {
206+ name: "declared ansi16 resume shows downgrade notice", from: colorTrue, to: color16,
207+ checkToasts: true, wantToasts: []string{"Terminal supports 16 colors; vev UI colors are reduced."},
208+ },
209+ {
210+ name: "forced monochrome resume shows no notice", from: colorTrue,
211+ to: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced},
212+ checkToasts: true,
213+ },
201 {name: "monochrome to truecolor", from: colorMono, to: colorTrue},214 {name: "monochrome to truecolor", from: colorMono, to: colorTrue},
202 {name: "ansi16 to truecolor", from: color16, to: colorTrue},215 {name: "ansi16 to truecolor", from: color16, to: colorTrue},
203 {name: "ansi16 to monochrome", from: color16, to: colorMono},216 {name: "ansi16 to monochrome", from: color16, to: colorMono},
@@ -250,6 +263,15 @@ func TestResumeAdoptsColorOfLatestHello(t *testing.T) {
250 require.NotZero(t, outs[0].Base)263 require.NotZero(t, outs[0].Base)
251 requireProfile(t, tt.to.Mode, data)264 requireProfile(t, tt.to.Mode, data)
252 265
266+ if tt.checkToasts {
267+ toasts, _ := visibleToasts(resumed)
268+ var got []string
269+ for _, toast := range toasts {
270+ got = append(got, toast.Message)
271+ }
272+ require.Equal(t, tt.wantToasts, got)
273+ }
274+
253 // Chrome styling follows the new mode as well.275 // Chrome styling follows the new mode as well.
254 reduced := tt.to.Mode != terminalcap.TrueColor276 reduced := tt.to.Mode != terminalcap.TrueColor
255 require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute)277 require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute)
M internal/usecase/daemon/attachment_output.go +6 -2
@@ -52,8 +52,12 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
5252 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
5353 // The replacement terminal's color claim wins over whatever the previous
5454 // 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
55+ // Only the mode changes the encoding; the source is stored but never
56+ // forces a renderer swap.
57+ color := terminalcap.Resolve(h.Env, h.Color).Color
58+ swap := color.Mode != ac.terminalCapabilities.Color.Mode
59+ ac.terminalCapabilities.Color = color
60+ if swap {
5761 ac.output.setColorProfile(colorprofile.Profile(color))
5862 }
5963 // A replacement terminal declares its own keyboard protocol.
@@ -52,8 +52,12 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
52 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics52 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
53 // The replacement terminal's color claim wins over whatever the previous53 // The replacement terminal's color claim wins over whatever the previous
54 // link negotiated: its renderer must encode for the terminal now attached.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 {55+ // Only the mode changes the encoding; the source is stored but never
56- ac.terminalCapabilities.Color = color56+ // forces a renderer swap.
57+ color := terminalcap.Resolve(h.Env, h.Color).Color
58+ swap := color.Mode != ac.terminalCapabilities.Color.Mode
59+ ac.terminalCapabilities.Color = color
60+ if swap {
57 ac.output.setColorProfile(colorprofile.Profile(color))61 ac.output.setColorProfile(colorprofile.Profile(color))
58 }62 }
59 // A replacement terminal declares its own keyboard protocol.63 // A replacement terminal declares its own keyboard protocol.
M internal/usecase/daemon/client.go +18 -10
@@ -712,19 +712,27 @@ func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) {
712712 }
713713 }
714714
715+// publishColorDowngradeNotice toasts ac when its terminal declared a reduced
716+// color mode (see colorDowngradeNotice).
717+func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) {
718+ message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color)
719+ if !ok {
720+ return
721+ }
722+ d.publishToast(ac, domain.Notification{
723+ Code: domain.NoticeUser,
724+ Severity: domain.NoticeWarn,
725+ Message: message,
726+ Time: d.clock.Now(),
727+ Count: 1,
728+ SessionID: sess.id,
729+ })
730+}
731+
715732 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
716733 d.touchMRU(sess)
717734 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
718- if message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color); ok {
719- d.publishToast(ac, domain.Notification{
720- Code: domain.NoticeUser,
721- Severity: domain.NoticeWarn,
722- Message: message,
723- Time: d.clock.Now(),
724- Count: 1,
725- SessionID: sess.id,
726- })
727- }
735+ d.publishColorDowngradeNotice(sess, ac)
728736 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
729737 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
730738 d.warnUnsupportedGraphics(ac)
@@ -712,19 +712,27 @@ func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) {
712 }712 }
713 }713 }
714 714
715+// publishColorDowngradeNotice toasts ac when its terminal declared a reduced
716+// color mode (see colorDowngradeNotice).
717+func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) {
718+ message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color)
719+ if !ok {
720+ return
721+ }
722+ d.publishToast(ac, domain.Notification{
723+ Code: domain.NoticeUser,
724+ Severity: domain.NoticeWarn,
725+ Message: message,
726+ Time: d.clock.Now(),
727+ Count: 1,
728+ SessionID: sess.id,
729+ })
730+}
731+
715 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {732 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
716 d.touchMRU(sess)733 d.touchMRU(sess)
717 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)734 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
718- if message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color); ok {735+ d.publishColorDowngradeNotice(sess, ac)
719- d.publishToast(ac, domain.Notification{
720- Code: domain.NoticeUser,
721- Severity: domain.NoticeWarn,
722- Message: message,
723- Time: d.clock.Now(),
724- Count: 1,
725- SessionID: sess.id,
726- })
727- }
728 d.applyHostTheme(sess, ac, themeui.Theme{}, true)736 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
729 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {737 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
730 d.warnUnsupportedGraphics(ac)738 d.warnUnsupportedGraphics(ac)
M internal/usecase/daemon/resume.go +5 -3
@@ -564,9 +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
567+ colorBefore := ac.terminalCapabilities.Color.Mode
568568 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
569- colorChanged := resumed != nil && resumed.terminalCapabilities.Color != colorBefore
569+ colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore
570570 d.mu.Unlock()
571571 ac.sendMu.Unlock()
572572 if err == nil && ok && sess != nil && resumed != nil {
@@ -577,7 +577,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
577577 if colorChanged {
578578 // The applied theme (chrome styles) was resolved for the previous
579579 // terminal's color mode; re-resolve it for the new one before the
580- // first paint.
580+ // first paint, and tell the user about a declared downgrade just as a
581+ // fresh attach would.
582+ d.publishColorDowngradeNotice(sess, resumed)
581583 d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false)
582584 }
583585 // A resumed unsupported attachment bypasses finishAttachedClient. Check
@@ -564,9 +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.Color567+ colorBefore := ac.terminalCapabilities.Color.Mode
568 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 != colorBefore569+ colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore
570 d.mu.Unlock()570 d.mu.Unlock()
571 ac.sendMu.Unlock()571 ac.sendMu.Unlock()
572 if err == nil && ok && sess != nil && resumed != nil {572 if err == nil && ok && sess != nil && resumed != nil {
@@ -577,7 +577,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
577 if colorChanged {577 if colorChanged {
578 // The applied theme (chrome styles) was resolved for the previous578 // The applied theme (chrome styles) was resolved for the previous
579 // terminal's color mode; re-resolve it for the new one before the579 // terminal's color mode; re-resolve it for the new one before the
580- // first paint.580+ // first paint, and tell the user about a declared downgrade just as a
581+ // fresh attach would.
582+ d.publishColorDowngradeNotice(sess, resumed)
581 d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false)583 d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false)
582 }584 }
583 // A resumed unsupported attachment bypasses finishAttachedClient. Check585 // A resumed unsupported attachment bypasses finishAttachedClient. Check

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