detached 9a421ca

feat(terminal): detect ANSI-16 and monochrome terminals

Classify TERM values without a truecolor signal: dumb, vt52/100/102/220 and
-m/-mono names are monochrome; linux, ansi, cons25 and -16color/-color names
are 16 colors. Add terminalcap.ParseColorMode for explicit overrides.
commit
9a421ca553513e4f85d18343a360bf4e7369b92b
author
brice · · 2026-10-03 12:34 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
4b57b07
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +119 -2
M internal/domain/terminalcap/capabilities.go +52 -2
@@ -1,7 +1,10 @@
11 // Package terminalcap owns pure terminal capability values and detection policy.
22 package terminalcap
33
4-import "strings"
4+import (
5+ "fmt"
6+ "strings"
7+)
58
69 // ColorMode is the color output mode selected for one attachment.
710 type ColorMode uint8
@@ -111,12 +114,59 @@ func Detect(env []string) Capabilities {
111114 if caps.Color.Source == SourceDeclared {
112115 return caps
113116 }
114- if strings.Contains(term, "256color") || term == "dumb" {
117+ if mode, ok := termColorMode(term); ok {
118+ caps.Color.Mode = mode
115119 caps.Color.Source = SourceDeclared
116120 }
117121 return caps
118122 }
119123
124+// termColorMode classifies a lowercase TERM value that carries no truecolor
125+// signal. It is deliberately conservative: only well-known terminfo names map
126+// to a constrained mode, and anything else reports ok=false so the caller keeps
127+// its unverified 256-color default. The rules apply in this order:
128+//
129+// - monochrome: dumb, vt52, vt100, vt102, vt220, or a name ending in -m or -mono;
130+// - 256 colors: any name containing "256color";
131+// - 16 colors: linux, ansi, cons25, or a name ending in -16color or -color.
132+func termColorMode(term string) (ColorMode, bool) {
133+ switch term {
134+ case "dumb", "vt52", "vt100", "vt102", "vt220":
135+ return Monochrome, true
136+ case "linux", "ansi", "cons25":
137+ return ANSI16, true
138+ }
139+ switch {
140+ case strings.HasSuffix(term, "-m"), strings.HasSuffix(term, "-mono"):
141+ return Monochrome, true
142+ case strings.Contains(term, "256color"):
143+ return ANSI256, true
144+ case strings.HasSuffix(term, "-16color"), strings.HasSuffix(term, "-color"):
145+ return ANSI16, true
146+ }
147+ return 0, false
148+}
149+
150+// ParseColorMode parses a user color override: auto, truecolor, 256, 16, or
151+// mono, case-insensitively and ignoring surrounding space. An empty value is
152+// auto. When auto is true the caller must use detection and mode is
153+// meaningless; an unknown value returns an error.
154+func ParseColorMode(value string) (mode ColorMode, auto bool, err error) {
155+ switch strings.ToLower(strings.TrimSpace(value)) {
156+ case "", "auto":
157+ return TrueColor, true, nil
158+ case "truecolor":
159+ return TrueColor, false, nil
160+ case "256":
161+ return ANSI256, false, nil
162+ case "16":
163+ return ANSI16, false, nil
164+ case "mono":
165+ return Monochrome, false, nil
166+ }
167+ return TrueColor, false, fmt.Errorf("invalid color mode %q (want auto, truecolor, 256, 16, or mono)", value)
168+}
169+
120170 func environmentValues(env []string) map[string]string {
121171 values := make(map[string]string, len(env))
122172 for _, entry := range env {
@@ -1,7 +1,10 @@
1 // Package terminalcap owns pure terminal capability values and detection policy.1 // Package terminalcap owns pure terminal capability values and detection policy.
2 package terminalcap2 package terminalcap
3 3
4-import "strings"4+import (
5+ "fmt"
6+ "strings"
7+)
5 8
6 // ColorMode is the color output mode selected for one attachment.9 // ColorMode is the color output mode selected for one attachment.
7 type ColorMode uint810 type ColorMode uint8
@@ -111,12 +114,59 @@ func Detect(env []string) Capabilities {
111 if caps.Color.Source == SourceDeclared {114 if caps.Color.Source == SourceDeclared {
112 return caps115 return caps
113 }116 }
114- if strings.Contains(term, "256color") || term == "dumb" {117+ if mode, ok := termColorMode(term); ok {
118+ caps.Color.Mode = mode
115 caps.Color.Source = SourceDeclared119 caps.Color.Source = SourceDeclared
116 }120 }
117 return caps121 return caps
118 }122 }
119 123
124+// termColorMode classifies a lowercase TERM value that carries no truecolor
125+// signal. It is deliberately conservative: only well-known terminfo names map
126+// to a constrained mode, and anything else reports ok=false so the caller keeps
127+// its unverified 256-color default. The rules apply in this order:
128+//
129+// - monochrome: dumb, vt52, vt100, vt102, vt220, or a name ending in -m or -mono;
130+// - 256 colors: any name containing "256color";
131+// - 16 colors: linux, ansi, cons25, or a name ending in -16color or -color.
132+func termColorMode(term string) (ColorMode, bool) {
133+ switch term {
134+ case "dumb", "vt52", "vt100", "vt102", "vt220":
135+ return Monochrome, true
136+ case "linux", "ansi", "cons25":
137+ return ANSI16, true
138+ }
139+ switch {
140+ case strings.HasSuffix(term, "-m"), strings.HasSuffix(term, "-mono"):
141+ return Monochrome, true
142+ case strings.Contains(term, "256color"):
143+ return ANSI256, true
144+ case strings.HasSuffix(term, "-16color"), strings.HasSuffix(term, "-color"):
145+ return ANSI16, true
146+ }
147+ return 0, false
148+}
149+
150+// ParseColorMode parses a user color override: auto, truecolor, 256, 16, or
151+// mono, case-insensitively and ignoring surrounding space. An empty value is
152+// auto. When auto is true the caller must use detection and mode is
153+// meaningless; an unknown value returns an error.
154+func ParseColorMode(value string) (mode ColorMode, auto bool, err error) {
155+ switch strings.ToLower(strings.TrimSpace(value)) {
156+ case "", "auto":
157+ return TrueColor, true, nil
158+ case "truecolor":
159+ return TrueColor, false, nil
160+ case "256":
161+ return ANSI256, false, nil
162+ case "16":
163+ return ANSI16, false, nil
164+ case "mono":
165+ return Monochrome, false, nil
166+ }
167+ return TrueColor, false, fmt.Errorf("invalid color mode %q (want auto, truecolor, 256, 16, or mono)", value)
168+}
169+
120 func environmentValues(env []string) map[string]string {170 func environmentValues(env []string) map[string]string {
121 values := make(map[string]string, len(env))171 values := make(map[string]string, len(env))
122 for _, entry := range env {172 for _, entry := range env {
M internal/domain/terminalcap/capabilities_test.go +67 -0
@@ -92,6 +92,32 @@ func TestDetect(t *testing.T) {
9292 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
9393 {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
9494 {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},
95+ {name: "empty TERM is unknown", env: nil, wantMode: ANSI256, wantSource: SourceUnknown},
96+ {name: "TERM=screen is unknown", env: []string{"TERM=screen"}, wantMode: ANSI256, wantSource: SourceUnknown},
97+ {name: "TERM=xterm is unknown", env: []string{"TERM=xterm"}, wantMode: ANSI256, wantSource: SourceUnknown},
98+ {name: "screen-256color", env: []string{"TERM=screen-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
99+ {name: "tmux-256color", env: []string{"TERM=tmux-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
100+ {name: "TERM is case-insensitive and trimmed", env: []string{"TERM= LINUX "}, wantMode: ANSI16, wantSource: SourceDeclared},
101+ {name: "dumb is monochrome", env: []string{"TERM=dumb"}, wantMode: Monochrome, wantSource: SourceDeclared},
102+ {name: "vt52 is monochrome", env: []string{"TERM=vt52"}, wantMode: Monochrome, wantSource: SourceDeclared},
103+ {name: "vt100 is monochrome", env: []string{"TERM=vt100"}, wantMode: Monochrome, wantSource: SourceDeclared},
104+ {name: "vt102 is monochrome", env: []string{"TERM=vt102"}, wantMode: Monochrome, wantSource: SourceDeclared},
105+ {name: "vt220 is monochrome", env: []string{"TERM=vt220"}, wantMode: Monochrome, wantSource: SourceDeclared},
106+ {name: "-mono suffix is monochrome", env: []string{"TERM=xterm-mono"}, wantMode: Monochrome, wantSource: SourceDeclared},
107+ {name: "-m suffix is monochrome", env: []string{"TERM=rxvt-m"}, wantMode: Monochrome, wantSource: SourceDeclared},
108+ {name: "vt320 is not assumed monochrome", env: []string{"TERM=vt320"}, wantMode: ANSI256, wantSource: SourceUnknown},
109+ {name: "linux console is 16 colors", env: []string{"TERM=linux"}, wantMode: ANSI16, wantSource: SourceDeclared},
110+ {name: "ansi is 16 colors", env: []string{"TERM=ansi"}, wantMode: ANSI16, wantSource: SourceDeclared},
111+ {name: "cons25 is 16 colors", env: []string{"TERM=cons25"}, wantMode: ANSI16, wantSource: SourceDeclared},
112+ {name: "-16color suffix", env: []string{"TERM=xterm-16color"}, wantMode: ANSI16, wantSource: SourceDeclared},
113+ {name: "-color suffix", env: []string{"TERM=xterm-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
114+ {name: "screen-color suffix", env: []string{"TERM=screen-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
115+ {name: "256color is not the -color suffix", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
116+ {name: "COLORTERM truecolor beats linux", env: []string{"TERM=linux", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared},
117+ {name: "COLORTERM 24bit beats dumb", env: []string{"TERM=dumb", "COLORTERM=24bit"}, wantMode: TrueColor, wantSource: SourceDeclared},
118+ {name: "direct terminfo beats mono suffix", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
119+ {name: "unrelated COLORTERM does not change TERM rules", env: []string{"TERM=linux", "COLORTERM=yes"}, wantMode: ANSI16, wantSource: SourceDeclared},
120+ {name: "kitty identity beats TERM rules", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
95121 {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
96122 }
97123
@@ -105,3 +131,44 @@ func TestDetect(t *testing.T) {
105131 })
106132 }
107133 }
134+
135+func TestParseColorMode(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ in string
139+ wantMode ColorMode
140+ wantAuto bool
141+ wantErr bool
142+ }{
143+ {name: "empty is auto", in: "", wantAuto: true},
144+ {name: "blank is auto", in: " \t", wantAuto: true},
145+ {name: "auto", in: "auto", wantAuto: true},
146+ {name: "auto is case-insensitive", in: " AUTO ", wantAuto: true},
147+ {name: "truecolor", in: "truecolor", wantMode: TrueColor},
148+ {name: "truecolor mixed case", in: "TrueColor", wantMode: TrueColor},
149+ {name: "256", in: "256", wantMode: ANSI256},
150+ {name: "16", in: " 16 ", wantMode: ANSI16},
151+ {name: "mono", in: "mono", wantMode: Monochrome},
152+ {name: "mono upper case", in: "MONO", wantMode: Monochrome},
153+ {name: "24bit is not accepted", in: "24bit", wantErr: true},
154+ {name: "256color is not accepted", in: "256color", wantErr: true},
155+ {name: "number out of set", in: "8", wantErr: true},
156+ {name: "monochrome spelled out is not accepted", in: "monochrome", wantErr: true},
157+ {name: "garbage", in: "rainbow", wantErr: true},
158+ }
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ mode, auto, err := ParseColorMode(tt.in)
162+ if tt.wantErr {
163+ require.Error(t, err)
164+ require.False(t, auto)
165+ return
166+ }
167+ require.NoError(t, err)
168+ require.Equal(t, tt.wantAuto, auto)
169+ if !auto {
170+ require.Equal(t, tt.wantMode, mode)
171+ }
172+ })
173+ }
174+}
@@ -92,6 +92,32 @@ func TestDetect(t *testing.T) {
92 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},92 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
93 {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},93 {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
94 {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},94 {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},
95+ {name: "empty TERM is unknown", env: nil, wantMode: ANSI256, wantSource: SourceUnknown},
96+ {name: "TERM=screen is unknown", env: []string{"TERM=screen"}, wantMode: ANSI256, wantSource: SourceUnknown},
97+ {name: "TERM=xterm is unknown", env: []string{"TERM=xterm"}, wantMode: ANSI256, wantSource: SourceUnknown},
98+ {name: "screen-256color", env: []string{"TERM=screen-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
99+ {name: "tmux-256color", env: []string{"TERM=tmux-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
100+ {name: "TERM is case-insensitive and trimmed", env: []string{"TERM= LINUX "}, wantMode: ANSI16, wantSource: SourceDeclared},
101+ {name: "dumb is monochrome", env: []string{"TERM=dumb"}, wantMode: Monochrome, wantSource: SourceDeclared},
102+ {name: "vt52 is monochrome", env: []string{"TERM=vt52"}, wantMode: Monochrome, wantSource: SourceDeclared},
103+ {name: "vt100 is monochrome", env: []string{"TERM=vt100"}, wantMode: Monochrome, wantSource: SourceDeclared},
104+ {name: "vt102 is monochrome", env: []string{"TERM=vt102"}, wantMode: Monochrome, wantSource: SourceDeclared},
105+ {name: "vt220 is monochrome", env: []string{"TERM=vt220"}, wantMode: Monochrome, wantSource: SourceDeclared},
106+ {name: "-mono suffix is monochrome", env: []string{"TERM=xterm-mono"}, wantMode: Monochrome, wantSource: SourceDeclared},
107+ {name: "-m suffix is monochrome", env: []string{"TERM=rxvt-m"}, wantMode: Monochrome, wantSource: SourceDeclared},
108+ {name: "vt320 is not assumed monochrome", env: []string{"TERM=vt320"}, wantMode: ANSI256, wantSource: SourceUnknown},
109+ {name: "linux console is 16 colors", env: []string{"TERM=linux"}, wantMode: ANSI16, wantSource: SourceDeclared},
110+ {name: "ansi is 16 colors", env: []string{"TERM=ansi"}, wantMode: ANSI16, wantSource: SourceDeclared},
111+ {name: "cons25 is 16 colors", env: []string{"TERM=cons25"}, wantMode: ANSI16, wantSource: SourceDeclared},
112+ {name: "-16color suffix", env: []string{"TERM=xterm-16color"}, wantMode: ANSI16, wantSource: SourceDeclared},
113+ {name: "-color suffix", env: []string{"TERM=xterm-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
114+ {name: "screen-color suffix", env: []string{"TERM=screen-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
115+ {name: "256color is not the -color suffix", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
116+ {name: "COLORTERM truecolor beats linux", env: []string{"TERM=linux", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared},
117+ {name: "COLORTERM 24bit beats dumb", env: []string{"TERM=dumb", "COLORTERM=24bit"}, wantMode: TrueColor, wantSource: SourceDeclared},
118+ {name: "direct terminfo beats mono suffix", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
119+ {name: "unrelated COLORTERM does not change TERM rules", env: []string{"TERM=linux", "COLORTERM=yes"}, wantMode: ANSI16, wantSource: SourceDeclared},
120+ {name: "kitty identity beats TERM rules", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
95 {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},121 {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
96 }122 }
97 123
@@ -105,3 +131,44 @@ func TestDetect(t *testing.T) {
105 })131 })
106 }132 }
107 }133 }
134+
135+func TestParseColorMode(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ in string
139+ wantMode ColorMode
140+ wantAuto bool
141+ wantErr bool
142+ }{
143+ {name: "empty is auto", in: "", wantAuto: true},
144+ {name: "blank is auto", in: " \t", wantAuto: true},
145+ {name: "auto", in: "auto", wantAuto: true},
146+ {name: "auto is case-insensitive", in: " AUTO ", wantAuto: true},
147+ {name: "truecolor", in: "truecolor", wantMode: TrueColor},
148+ {name: "truecolor mixed case", in: "TrueColor", wantMode: TrueColor},
149+ {name: "256", in: "256", wantMode: ANSI256},
150+ {name: "16", in: " 16 ", wantMode: ANSI16},
151+ {name: "mono", in: "mono", wantMode: Monochrome},
152+ {name: "mono upper case", in: "MONO", wantMode: Monochrome},
153+ {name: "24bit is not accepted", in: "24bit", wantErr: true},
154+ {name: "256color is not accepted", in: "256color", wantErr: true},
155+ {name: "number out of set", in: "8", wantErr: true},
156+ {name: "monochrome spelled out is not accepted", in: "monochrome", wantErr: true},
157+ {name: "garbage", in: "rainbow", wantErr: true},
158+ }
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ mode, auto, err := ParseColorMode(tt.in)
162+ if tt.wantErr {
163+ require.Error(t, err)
164+ require.False(t, auto)
165+ return
166+ }
167+ require.NoError(t, err)
168+ require.Equal(t, tt.wantAuto, auto)
169+ if !auto {
170+ require.Equal(t, tt.wantMode, mode)
171+ }
172+ })
173+ }
174+}

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