detached da6f075

fix(client): mark picker problems without color in monochrome

Monochrome drops the fixed problem dot colors, leaving error, warning and
healthy rows indistinguishable. Draw error and warning dots bold and dim
the tree lines instead of relying on the grey foreground. Truecolor,
256-color and ANSI-16 bytes are unchanged.
commit
da6f075a9575f6845133c4d39275ace0855de57f
author
brice · · 2026-10-03 13:36 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
1a70faf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +76 -4
M internal/usecase/client/picker_interaction.go +5 -1
@@ -379,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
379379 border := renderer.DefaultStyle()
380380 border.Attrs |= renderer.AttrDim
381381 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
382- inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)
382+ styles := r.renderStyles
383+ if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome {
384+ styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)}
385+ }
386+ inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...)
383387 copyFrameRect(base, presentation.Inner, inner)
384388
385389 // Only the box is written: the session stays on screen around it. A box
@@ -379,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
379 border := renderer.DefaultStyle()379 border := renderer.DefaultStyle()
380 border.Attrs |= renderer.AttrDim380 border.Attrs |= renderer.AttrDim
381 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())381 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
382- inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)382+ styles := r.renderStyles
383+ if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome {
384+ styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)}
385+ }
386+ inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...)
383 copyFrameRect(base, presentation.Inner, inner)387 copyFrameRect(base, presentation.Inner, inner)
384 388
385 // Only the box is written: the session stays on screen around it. A box389 // Only the box is written: the session stays on screen around it. A box
M internal/usecase/client/reduced_color_test.go +50 -0
@@ -1,12 +1,15 @@
11 package client
22
33 import (
4+ "regexp"
45 "testing"
56
67 "github.com/stretchr/testify/require"
78
89 ansirenderer "github.com/bnema/vev-vt/ansi"
910 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/picker"
1013 )
1114
1215 func TestPickerStaysLegibleInReducedColor(t *testing.T) {
@@ -51,3 +54,50 @@ func TestToastBorderFollowsColorProfile(t *testing.T) {
5154 require.Contains(t, lines[0], "\x1b[22m")
5255 require.NotContains(t, lines[0], "\x1b[39m")
5356 }
57+
58+// TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes
59+// the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is
60+// quantized by the renderer, and monochrome marks error and warning dots bold
61+// since no color survives. Informational statuses never gain weight.
62+func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) {
63+ snapshot := pickerSnapshotFixture()
64+ snapshot.Lines = []protocol.PickerLine{
65+ {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError},
66+ {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale},
67+ {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion},
68+ }
69+ snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"}
70+ // dot returns the SGR group in effect for the dot on the row labelled label.
71+ dot := func(out, label string) string {
72+ row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`)
73+ m := row.FindStringSubmatch(out)
74+ require.NotNil(t, m, "no problem dot after %q in %q", label, out)
75+ return m[1]
76+ }
77+ tests := []struct {
78+ name string
79+ profile ansirenderer.ColorProfile
80+ want map[string]string // label -> exact SGR before the dot
81+ }{
82+ {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
83+ {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
84+ {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}},
85+ {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
90+ out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
91+ for label, want := range tt.want {
92+ got := dot(out, label)
93+ if want == "" {
94+ // ANSI-16 colors are chosen by the renderer's quantizer: only
95+ // require a basic or bright palette slot, never extended color.
96+ require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label)
97+ continue
98+ }
99+ require.Equal(t, want, got, label)
100+ }
101+ })
102+ }
103+}
@@ -1,12 +1,15 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "regexp"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
7 8
8 ansirenderer "github.com/bnema/vev-vt/ansi"9 ansirenderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/picker"
10 )13 )
11 14
12 func TestPickerStaysLegibleInReducedColor(t *testing.T) {15 func TestPickerStaysLegibleInReducedColor(t *testing.T) {
@@ -51,3 +54,50 @@ func TestToastBorderFollowsColorProfile(t *testing.T) {
51 require.Contains(t, lines[0], "\x1b[22m")54 require.Contains(t, lines[0], "\x1b[22m")
52 require.NotContains(t, lines[0], "\x1b[39m")55 require.NotContains(t, lines[0], "\x1b[39m")
53 }56 }
57+
58+// TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes
59+// the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is
60+// quantized by the renderer, and monochrome marks error and warning dots bold
61+// since no color survives. Informational statuses never gain weight.
62+func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) {
63+ snapshot := pickerSnapshotFixture()
64+ snapshot.Lines = []protocol.PickerLine{
65+ {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError},
66+ {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale},
67+ {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion},
68+ }
69+ snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"}
70+ // dot returns the SGR group in effect for the dot on the row labelled label.
71+ dot := func(out, label string) string {
72+ row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`)
73+ m := row.FindStringSubmatch(out)
74+ require.NotNil(t, m, "no problem dot after %q in %q", label, out)
75+ return m[1]
76+ }
77+ tests := []struct {
78+ name string
79+ profile ansirenderer.ColorProfile
80+ want map[string]string // label -> exact SGR before the dot
81+ }{
82+ {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
83+ {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
84+ {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}},
85+ {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
90+ out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
91+ for label, want := range tt.want {
92+ got := dot(out, label)
93+ if want == "" {
94+ // ANSI-16 colors are chosen by the renderer's quantizer: only
95+ // require a basic or bright palette slot, never extended color.
96+ require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label)
97+ continue
98+ }
99+ require.Equal(t, want, got, label)
100+ }
101+ })
102+ }
103+}
M internal/usecase/picker/model.go +21 -3
@@ -61,6 +61,21 @@ type RenderStyles struct {
6161 Status renderer.Style // one-row picker-local contextual help
6262 SearchMatch renderer.Style // matched runes on ordinary rows
6363 SelectionMatch renderer.Style // matched runes on the selected row
64+ // ProblemBold draws error and warning dots bold. A monochrome terminal
65+ // drops the fixed problem colors, so weight is what sets them apart.
66+ ProblemBold bool
67+}
68+
69+// DefaultRenderStyles returns the picker's built-in styles. monochrome selects
70+// the attribute-only variant: tree lines are dimmed instead of grey and
71+// problem dots are bold, since no color survives on such a terminal.
72+func DefaultRenderStyles(monochrome bool) RenderStyles {
73+ styles := defaultRenderStyles()
74+ if monochrome {
75+ styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style
76+ styles.ProblemBold = true
77+ }
78+ return styles
6479 }
6580
6681 func defaultRenderStyles() RenderStyles {
@@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int {
585600
586601 // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587602 // its own color even on the inverse selected row.
588-func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {
603+func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) {
589604 if badge == "" {
590605 return
591606 }
@@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge
593608 style.Inverse = false
594609 style.Foreground = lineStatusColor(status)
595610 style.HasForegroundRGB = false
611+ if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) {
612+ style.Bold = true
613+ }
596614 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597615 }
598616
@@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
681699 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
682700
683701 if r.section() {
684- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
702+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
685703 continue
686704 }
687705 if r.rendersAsHeader() {
@@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
697715 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
698716 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
699717 }
700- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
718+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
701719 continue
702720 }
703721
@@ -61,6 +61,21 @@ type RenderStyles struct {
61 Status renderer.Style // one-row picker-local contextual help61 Status renderer.Style // one-row picker-local contextual help
62 SearchMatch renderer.Style // matched runes on ordinary rows62 SearchMatch renderer.Style // matched runes on ordinary rows
63 SelectionMatch renderer.Style // matched runes on the selected row63 SelectionMatch renderer.Style // matched runes on the selected row
64+ // ProblemBold draws error and warning dots bold. A monochrome terminal
65+ // drops the fixed problem colors, so weight is what sets them apart.
66+ ProblemBold bool
67+}
68+
69+// DefaultRenderStyles returns the picker's built-in styles. monochrome selects
70+// the attribute-only variant: tree lines are dimmed instead of grey and
71+// problem dots are bold, since no color survives on such a terminal.
72+func DefaultRenderStyles(monochrome bool) RenderStyles {
73+ styles := defaultRenderStyles()
74+ if monochrome {
75+ styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style
76+ styles.ProblemBold = true
77+ }
78+ return styles
64 }79 }
65 80
66 func defaultRenderStyles() RenderStyles {81 func defaultRenderStyles() RenderStyles {
@@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int {
585 600
586 // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps601 // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587 // its own color even on the inverse selected row.602 // its own color even on the inverse selected row.
588-func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {603+func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) {
589 if badge == "" {604 if badge == "" {
590 return605 return
591 }606 }
@@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge
593 style.Inverse = false608 style.Inverse = false
594 style.Foreground = lineStatusColor(status)609 style.Foreground = lineStatusColor(status)
595 style.HasForegroundRGB = false610 style.HasForegroundRGB = false
611+ if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) {
612+ style.Bold = true
613+ }
596 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)614 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597 }615 }
598 616
@@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
681 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)699 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
682 700
683 if r.section() {701 if r.section() {
684- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)702+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
685 continue703 continue
686 }704 }
687 if r.rendersAsHeader() {705 if r.rendersAsHeader() {
@@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
697 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)715 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
698 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)716 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
699 }717 }
700- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)718+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
701 continue719 continue
702 }720 }
703 721

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