detached 25dcf71

fix(client): render pickers with the terminal color profile

The session picker and the move picker hardcoded a truecolor renderer, so
an ANSI-256 terminal received RGB SGR from picker boxes (the #280
regression). Both now take the profile from the detected terminal
capability carried by AttachmentEnvironment.TrueColor.
commit
25dcf71b8b0d73b2d46fbba561cc485c5b48e831
author
brice · · 2026-09-22 19:51 UTC
parent
0f340fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +71 -19
M internal/app/broker_client.go +1 -1
@@ -218,7 +218,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
218218 if sessionEnv.Cwd != "" {
219219 attachmentEnv.Cwd = sessionEnv.Cwd
220220 }
221- picker := client.NewPicker(clk, 0)
221+ picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
222222 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}
223223 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
224224 Connector: cfg.Connector,
@@ -218,7 +218,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
218 if sessionEnv.Cwd != "" {218 if sessionEnv.Cwd != "" {
219 attachmentEnv.Cwd = sessionEnv.Cwd219 attachmentEnv.Cwd = sessionEnv.Cwd
220 }220 }
221- picker := client.NewPicker(clk, 0)221+ picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
222 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}222 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}
223 supervisor, err := client.NewSupervisor(client.SupervisorConfig{223 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
224 Connector: cfg.Connector,224 Connector: cfg.Connector,
M internal/app/broker_offline_client_render_test.go +1 -1
@@ -104,7 +104,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
104104 // daemon with a single session, so Render produces a real frame at any size.
105105 func offlineRenderPicker(t *testing.T) *client.Picker {
106106 t.Helper()
107- picker := client.NewPicker(nil, 0)
107+ picker := client.NewPicker(nil, 0, true)
108108 picker.ApplySnapshot(ports.BrokerSnapshot{
109109 Epoch: 1,
110110 Revision: 1,
@@ -104,7 +104,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
104 // daemon with a single session, so Render produces a real frame at any size.104 // daemon with a single session, so Render produces a real frame at any size.
105 func offlineRenderPicker(t *testing.T) *client.Picker {105 func offlineRenderPicker(t *testing.T) *client.Picker {
106 t.Helper()106 t.Helper()
107- picker := client.NewPicker(nil, 0)107+ picker := client.NewPicker(nil, 0, true)
108 picker.ApplySnapshot(ports.BrokerSnapshot{108 picker.ApplySnapshot(ports.BrokerSnapshot{
109 Epoch: 1,109 Epoch: 1,
110 Revision: 1,110 Revision: 1,
M internal/usecase/client/attachment_session.go +1 -1
@@ -285,7 +285,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
285285 if overlay != nil {
286286 repaint = overlay.overlayRepaint()
287287 }
288- picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()}
288+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
289289 defer picker.stopEscape()
290290 var inputSeq uint64
291291 for {
@@ -285,7 +285,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
285 if overlay != nil {285 if overlay != nil {
286 repaint = overlay.overlayRepaint()286 repaint = overlay.overlayRepaint()
287 }287 }
288- picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()}288+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
289 defer picker.stopEscape()289 defer picker.stopEscape()
290 var inputSeq uint64290 var inputSeq uint64
291 for {291 for {
M internal/usecase/client/move_picker_overlay.go +2 -3
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "time"
77
8- ansirenderer "github.com/bnema/vev-vt/ansi"
98 "github.com/bnema/vev/internal/domain"
109 "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol"
@@ -39,8 +38,8 @@ type movePickerOverlay struct {
3938 presenting bool
4039 }
4140
42-func newMovePickerOverlay() *movePickerOverlay {
43- return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)}
41+func newMovePickerOverlay(trueColor bool) *movePickerOverlay {
42+ return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}
4443 }
4544
4645 // offer opens the namespace a move offer names. A superseding offer retires
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 "time"6 "time"
7 7
8- ansirenderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
@@ -39,8 +38,8 @@ type movePickerOverlay struct {
39 presenting bool38 presenting bool
40 }39 }
41 40
42-func newMovePickerOverlay() *movePickerOverlay {41+func newMovePickerOverlay(trueColor bool) *movePickerOverlay {
43- return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)}42+ return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}
44 }43 }
45 44
46 // offer opens the namespace a move offer names. A superseding offer retires45 // offer opens the namespace a move offer names. A superseding offer retires
M internal/usecase/client/picker_composition.go +5 -3
@@ -48,9 +48,11 @@ func PickerPresentation(state State) bool {
4848 }
4949
5050 // NewPicker composes one client-owned picker. A nil clock uses the system
51-// clock, and a zero freshness uses the catalogue default.
52-func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {
53- return &Picker{controller: newPickerController(clock, freshness)}
51+// clock, and a zero freshness uses the catalogue default. trueColor is the
52+// terminal's detected color capability (AttachmentEnvironment.TrueColor); the
53+// picker renders indexed colors without it.
54+func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker {
55+ return &Picker{controller: newPickerController(clock, freshness, trueColor)}
5456 }
5557
5658 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.
@@ -48,9 +48,11 @@ func PickerPresentation(state State) bool {
48 }48 }
49 49
50 // NewPicker composes one client-owned picker. A nil clock uses the system50 // NewPicker composes one client-owned picker. A nil clock uses the system
51-// clock, and a zero freshness uses the catalogue default.51+// clock, and a zero freshness uses the catalogue default. trueColor is the
52-func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {52+// terminal's detected color capability (AttachmentEnvironment.TrueColor); the
53- return &Picker{controller: newPickerController(clock, freshness)}53+// picker renders indexed colors without it.
54+func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker {
55+ return &Picker{controller: newPickerController(clock, freshness, trueColor)}
54 }56 }
55 57
56 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.58 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.
M internal/usecase/client/picker_composition_external_test.go +1 -1
@@ -19,7 +19,7 @@ import (
1919 // so it could never resolve a selection into an attachment.
2020 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
2121 geometry := domain.Size{Cols: 80, Rows: 24}
22- picker := client.NewPicker(nil, 0)
22+ picker := client.NewPicker(nil, 0, true)
2323 require.NotNil(t, picker)
2424
2525 // The exact assignment an app composition performs.
@@ -19,7 +19,7 @@ import (
19 // so it could never resolve a selection into an attachment.19 // so it could never resolve a selection into an attachment.
20 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {20 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
21 geometry := domain.Size{Cols: 80, Rows: 24}21 geometry := domain.Size{Cols: 80, Rows: 24}
22- picker := client.NewPicker(nil, 0)22+ picker := client.NewPicker(nil, 0, true)
23 require.NotNil(t, picker)23 require.NotNil(t, picker)
24 24
25 // The exact assignment an app composition performs.25 // The exact assignment an app composition performs.
M internal/usecase/client/picker_controller.go +2 -3
@@ -5,7 +5,6 @@ import (
55 "sync"
66 "time"
77
8- ansirenderer "github.com/bnema/vev-vt/ansi"
98 "github.com/bnema/vev/internal/domain"
109 "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol"
@@ -112,14 +111,14 @@ type pickerController struct {
112111 // newPickerController builds a picker over an empty catalogue. The picker owns
113112 // input from the start: P5.2b presents it for the whole picker presentation and
114113 // has no session pipeline to defer to.
115-func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController {
114+func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {
116115 if supervisorNil(clock) {
117116 clock = systemClock{}
118117 }
119118 controller := &pickerController{
120119 clock: clock,
121120 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
122- renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor),
121+ renderer: newPickerRenderer(pickerColorProfile(trueColor)),
123122 ownsInput: true,
124123 opsReady: make(chan struct{}, 1),
125124 }
@@ -5,7 +5,6 @@ import (
5 "sync"5 "sync"
6 "time"6 "time"
7 7
8- ansirenderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
@@ -112,14 +111,14 @@ type pickerController struct {
112 // newPickerController builds a picker over an empty catalogue. The picker owns111 // newPickerController builds a picker over an empty catalogue. The picker owns
113 // input from the start: P5.2b presents it for the whole picker presentation and112 // input from the start: P5.2b presents it for the whole picker presentation and
114 // has no session pipeline to defer to.113 // has no session pipeline to defer to.
115-func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController {114+func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {
116 if supervisorNil(clock) {115 if supervisorNil(clock) {
117 clock = systemClock{}116 clock = systemClock{}
118 }117 }
119 controller := &pickerController{118 controller := &pickerController{
120 clock: clock,119 clock: clock,
121 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),120 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
122- renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor),121+ renderer: newPickerRenderer(pickerColorProfile(trueColor)),
123 ownsInput: true,122 ownsInput: true,
124 opsReady: make(chan struct{}, 1),123 opsReady: make(chan struct{}, 1),
125 }124 }
M internal/usecase/client/picker_controller_test.go +3 -3
@@ -115,7 +115,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision,
115115 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
116116 t.Helper()
117117 clock := newSupervisorTestClock()
118- return newPickerController(clock, pickerTestFreshness), clock
118+ return newPickerController(clock, pickerTestFreshness, true), clock
119119 }
120120
121121 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
@@ -423,7 +423,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
423423 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
424424 return service, nil
425425 })
426- controller := newPickerController(clock, pickerTestFreshness)
426+ controller := newPickerController(clock, pickerTestFreshness, true)
427427 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
428428
429429 runErr := make(chan error, 1)
@@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
468468 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
469469 return service, nil
470470 })
471- controller := newPickerController(clock, pickerTestFreshness)
471+ controller := newPickerController(clock, pickerTestFreshness, true)
472472 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
473473
474474 runErr := make(chan error, 1)
@@ -115,7 +115,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision,
115 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {115 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
116 t.Helper()116 t.Helper()
117 clock := newSupervisorTestClock()117 clock := newSupervisorTestClock()
118- return newPickerController(clock, pickerTestFreshness), clock118+ return newPickerController(clock, pickerTestFreshness, true), clock
119 }119 }
120 120
121 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {121 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
@@ -423,7 +423,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
423 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))423 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
424 return service, nil424 return service, nil
425 })425 })
426- controller := newPickerController(clock, pickerTestFreshness)426+ controller := newPickerController(clock, pickerTestFreshness, true)
427 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})427 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
428 428
429 runErr := make(chan error, 1)429 runErr := make(chan error, 1)
@@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
468 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))468 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
469 return service, nil469 return service, nil
470 })470 })
471- controller := newPickerController(clock, pickerTestFreshness)471+ controller := newPickerController(clock, pickerTestFreshness, true)
472 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})472 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
473 473
474 runErr := make(chan error, 1)474 runErr := make(chan error, 1)
M internal/usecase/client/picker_interaction.go +10 -0
@@ -432,6 +432,16 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
432432 return &pickerRenderer{profile: profile}
433433 }
434434
435+// pickerColorProfile maps the terminal's detected color capability to the
436+// picker renderer profile. A terminal without truecolor gets indexed colors,
437+// so RGB surfaces are quantized instead of dropped (#280).
438+func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
439+ if trueColor {
440+ return ansirenderer.ColorProfileTrueColor
441+ }
442+ return ansirenderer.ColorProfileANSI256
443+}
444+
435445 // render composes the loop model into terminal bytes for one display
436446 // refresh. Previews arrive as source data (picker_preview.go) and are attached
437447 // to the model by the caller; a full redraw every refresh keeps the shadow
@@ -432,6 +432,16 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
432 return &pickerRenderer{profile: profile}432 return &pickerRenderer{profile: profile}
433 }433 }
434 434
435+// pickerColorProfile maps the terminal's detected color capability to the
436+// picker renderer profile. A terminal without truecolor gets indexed colors,
437+// so RGB surfaces are quantized instead of dropped (#280).
438+func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
439+ if trueColor {
440+ return ansirenderer.ColorProfileTrueColor
441+ }
442+ return ansirenderer.ColorProfileANSI256
443+}
444+
435 // render composes the loop model into terminal bytes for one display445 // render composes the loop model into terminal bytes for one display
436 // refresh. Previews arrive as source data (picker_preview.go) and are attached446 // refresh. Previews arrive as source data (picker_preview.go) and are attached
437 // to the model by the caller; a full redraw every refresh keeps the shadow447 // to the model by the caller; a full redraw every refresh keeps the shadow
M internal/usecase/client/picker_interaction_test.go +42 -0
@@ -61,6 +61,48 @@ func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) {
6161 require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))
6262 }
6363
64+// TestPickerRenderersFollowTerminalColorProfile guards #280 on the broker
65+// client: both the session picker and the move picker render with the
66+// terminal's detected color profile, so an ANSI-256 terminal never receives
67+// RGB SGR sequences from a picker box.
68+func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
69+ loop := pickerLoopFixture(t)
70+ styles := picker.RenderStyles{
71+ Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
72+ Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
73+ Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(),
74+ SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
75+ }
76+ renderers := map[string]func(trueColor bool) *pickerRenderer{
77+ "session picker": func(trueColor bool) *pickerRenderer {
78+ return NewPicker(nil, 0, trueColor).controller.renderer
79+ },
80+ "move picker": func(trueColor bool) *pickerRenderer {
81+ return newMovePickerOverlay(trueColor).renderer
82+ },
83+ }
84+ for name, build := range renderers {
85+ for _, tc := range []struct {
86+ trueColor bool
87+ profile ansirenderer.ColorProfile
88+ want string
89+ forbidden string
90+ }{
91+ {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92+ {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
93+ } {
94+ t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {
95+ r := build(tc.trueColor)
96+ require.Equal(t, tc.profile, r.profile)
97+ r.renderStyles = []picker.RenderStyles{styles}
98+ output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
99+ require.Regexp(t, tc.want, output)
100+ require.NotRegexp(t, tc.forbidden, output)
101+ })
102+ }
103+ }
104+}
105+
64106 func rgbPickerStyle() renderer.Style {
65107 style := renderer.DefaultStyle()
66108 style.HasForegroundRGB = true
@@ -61,6 +61,48 @@ func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) {
61 require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))61 require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))
62 }62 }
63 63
64+// TestPickerRenderersFollowTerminalColorProfile guards #280 on the broker
65+// client: both the session picker and the move picker render with the
66+// terminal's detected color profile, so an ANSI-256 terminal never receives
67+// RGB SGR sequences from a picker box.
68+func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
69+ loop := pickerLoopFixture(t)
70+ styles := picker.RenderStyles{
71+ Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
72+ Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
73+ Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(),
74+ SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
75+ }
76+ renderers := map[string]func(trueColor bool) *pickerRenderer{
77+ "session picker": func(trueColor bool) *pickerRenderer {
78+ return NewPicker(nil, 0, trueColor).controller.renderer
79+ },
80+ "move picker": func(trueColor bool) *pickerRenderer {
81+ return newMovePickerOverlay(trueColor).renderer
82+ },
83+ }
84+ for name, build := range renderers {
85+ for _, tc := range []struct {
86+ trueColor bool
87+ profile ansirenderer.ColorProfile
88+ want string
89+ forbidden string
90+ }{
91+ {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92+ {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
93+ } {
94+ t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {
95+ r := build(tc.trueColor)
96+ require.Equal(t, tc.profile, r.profile)
97+ r.renderStyles = []picker.RenderStyles{styles}
98+ output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
99+ require.Regexp(t, tc.want, output)
100+ require.NotRegexp(t, tc.forbidden, output)
101+ })
102+ }
103+ }
104+}
105+
64 func rgbPickerStyle() renderer.Style {106 func rgbPickerStyle() renderer.Style {
65 style := renderer.DefaultStyle()107 style := renderer.DefaultStyle()
66 style.HasForegroundRGB = true108 style.HasForegroundRGB = true
M internal/usecase/client/supervisor_attach_test.go +1 -1
@@ -1305,7 +1305,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
13051305 return admitted, nil
13061306 })
13071307
1308- controller := newPickerController(clock, pickerTestFreshness)
1308+ controller := newPickerController(clock, pickerTestFreshness, true)
13091309 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })
13101310 sup := mustSupervisor(t, SupervisorConfig{
13111311 Connector: connector,
@@ -1305,7 +1305,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
1305 return admitted, nil1305 return admitted, nil
1306 })1306 })
1307 1307
1308- controller := newPickerController(clock, pickerTestFreshness)1308+ controller := newPickerController(clock, pickerTestFreshness, true)
1309 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })1309 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })
1310 sup := mustSupervisor(t, SupervisorConfig{1310 sup := mustSupervisor(t, SupervisorConfig{
1311 Connector: connector,1311 Connector: connector,
M internal/usecase/client/supervisor_test.go +2 -2
@@ -687,7 +687,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
687687 }),
688688 Terminal: newSupervisorTestTerminal(reader),
689689 Clock: clock,
690- Picker: newPickerController(clock, pickerTestFreshness),
690+ Picker: newPickerController(clock, pickerTestFreshness, true),
691691 Render: func(state State) {
692692 renders <- state
693693 },
@@ -1466,7 +1466,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
14661466 })
14671467 s := mustSupervisor(t, SupervisorConfig{
14681468 Connector: connector, Terminal: terminal, Clock: clock,
1469- Picker: NewPicker(clock, 0),
1469+ Picker: NewPicker(clock, 0, true),
14701470 Notify: func(State, error) { reached <- struct{}{} },
14711471 })
14721472 ctx, cancel := context.WithCancel(context.Background())
@@ -687,7 +687,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
687 }),687 }),
688 Terminal: newSupervisorTestTerminal(reader),688 Terminal: newSupervisorTestTerminal(reader),
689 Clock: clock,689 Clock: clock,
690- Picker: newPickerController(clock, pickerTestFreshness),690+ Picker: newPickerController(clock, pickerTestFreshness, true),
691 Render: func(state State) {691 Render: func(state State) {
692 renders <- state692 renders <- state
693 },693 },
@@ -1466,7 +1466,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
1466 })1466 })
1467 s := mustSupervisor(t, SupervisorConfig{1467 s := mustSupervisor(t, SupervisorConfig{
1468 Connector: connector, Terminal: terminal, Clock: clock,1468 Connector: connector, Terminal: terminal, Clock: clock,
1469- Picker: NewPicker(clock, 0),1469+ Picker: NewPicker(clock, 0, true),
1470 Notify: func(State, error) { reached <- struct{}{} },1470 Notify: func(State, error) { reached <- struct{}{} },
1471 })1471 })
1472 ctx, cancel := context.WithCancel(context.Background())1472 ctx, cancel := context.WithCancel(context.Background())

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