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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 195 | 195 | name string | |
| 196 | 196 | from, to terminalcap.ColorCapabilities | |
| 197 | 197 | 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 | 203 | {name: "truecolor to monochrome", from: colorTrue, to: colorMono}, | |
| 200 | 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 | 214 | {name: "monochrome to truecolor", from: colorMono, to: colorTrue}, | |
| 202 | 215 | {name: "ansi16 to truecolor", from: color16, to: colorTrue}, | |
| 203 | 216 | {name: "ansi16 to monochrome", from: color16, to: colorMono}, | |
| @@ -250,6 +263,15 @@ func TestResumeAdoptsColorOfLatestHello(t *testing.T) { | |||
| 250 | 263 | require.NotZero(t, outs[0].Base) | |
| 251 | 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 | 275 | // Chrome styling follows the new mode as well. | |
| 254 | 276 | reduced := tt.to.Mode != terminalcap.TrueColor | |
| 255 | 277 | require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute) | |
| @@ -195,9 +195,22 @@ func TestResumeAdoptsColorOfLatestHello(t *testing.T) { | |||||
| 195 | name string | 195 | name string | ||
| 196 | from, to terminalcap.ColorCapabilities | 196 | from, to terminalcap.ColorCapabilities | ||
| 197 | liveResume bool // resume while the old link is still registered | 197 | 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.TrueColor | 276 | 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, | |||
| 52 | 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | |
| 53 | 53 | // The replacement terminal's color claim wins over whatever the previous | |
| 54 | 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 | |
| 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 { | |
| 57 | 61 | ac.output.setColorProfile(colorprofile.Profile(color)) | |
| 58 | 62 | } | |
| 59 | 63 | // 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.KittyDirectGraphics | 52 | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | ||
| 53 | // The replacement terminal's color claim wins over whatever the previous | 53 | // 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 = color | 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 { | |||
| 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) { | |||
| 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 | 732 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | |
| 716 | 733 | d.touchMRU(sess) | |
| 717 | 734 | 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) | |
| 728 | 736 | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | |
| 729 | 737 | if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) { | |
| 730 | 738 | 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 | |||
| 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 | + | colorBefore := ac.terminalCapabilities.Color.Mode | |
| 568 | 568 | 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 | |
| 570 | 570 | d.mu.Unlock() | |
| 571 | 571 | ac.sendMu.Unlock() | |
| 572 | 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 | 577 | if colorChanged { | |
| 578 | 578 | // The applied theme (chrome styles) was resolved for the previous | |
| 579 | 579 | // 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 | 583 | d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false) | |
| 582 | 584 | } | |
| 583 | 585 | // 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.Color | 567 | + | 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 != colorBefore | 569 | + | 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 previous | 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 | 579 | // 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. Check | 585 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | ||