detached b5ab07f

tui: strip syntax highlight backgrounds

commit
b5ab07f9a0fbce8f29fc936b7e98332d229e1d9d
author
patriceckhart · · 2026-05-04 13:54 UTC
parent
17dec30
signature
unsigned
1 file · +61 -1
M internal/tui/highlight.go +61 -1
@@ -48,7 +48,7 @@ func HighlightCode(src, lang string) []string {
4848 highlightCache.store(lang, src, out)
4949 return out
5050 }
51- out := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
51+ out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
5252 highlightCache.store(lang, src, out)
5353 return out
5454 }
@@ -110,6 +110,66 @@ func LanguageFromPath(p string) string {
110110
111111 // chooseLexer picks the best lexer for a language hint; falls back to
112112 // nil (no highlighting) if the hint is empty or unknown.
113+// stripANSIBackgrounds removes SGR background-color attributes emitted by
114+// terminal syntax formatters while preserving foreground colors and styles.
115+// Chroma's inherited styles can assign black backgrounds to a few tokens
116+// (notably punctuation/error-ish spans), which looks like random black blocks
117+// inside zot's dark-gray tool boxes. Foreground color is enough for code.
118+func stripANSIBackgrounds(s string) string {
119+ var out strings.Builder
120+ out.Grow(len(s))
121+ for i := 0; i < len(s); {
122+ if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
123+ out.WriteByte(s[i])
124+ i++
125+ continue
126+ }
127+ j := i + 2
128+ for j < len(s) && s[j] != 'm' {
129+ j++
130+ }
131+ if j >= len(s) {
132+ out.WriteString(s[i:])
133+ break
134+ }
135+ params := strings.Split(s[i+2:j], ";")
136+ kept := make([]string, 0, len(params))
137+ for p := 0; p < len(params); p++ {
138+ param := params[p]
139+ switch param {
140+ case "48":
141+ // 48;5;N or 48;2;R;G;B background color.
142+ if p+1 < len(params) && params[p+1] == "5" {
143+ p += 2
144+ continue
145+ }
146+ if p+1 < len(params) && params[p+1] == "2" {
147+ p += 4
148+ continue
149+ }
150+ continue
151+ case "49":
152+ continue
153+ }
154+ // 40-47 and 100-107 are ANSI background colors.
155+ if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
156+ continue
157+ }
158+ if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
159+ continue
160+ }
161+ kept = append(kept, param)
162+ }
163+ if len(kept) > 0 {
164+ out.WriteString("\x1b[")
165+ out.WriteString(strings.Join(kept, ";"))
166+ out.WriteByte('m')
167+ }
168+ i = j + 1
169+ }
170+ return out.String()
171+}
172+
113173 func chooseLexer(lang string) chroma.Lexer {
114174 if lang == "" {
115175 return nil
@@ -48,7 +48,7 @@ func HighlightCode(src, lang string) []string {
48 highlightCache.store(lang, src, out)48 highlightCache.store(lang, src, out)
49 return out49 return out
50 }50 }
51- out := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")51+ out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
52 highlightCache.store(lang, src, out)52 highlightCache.store(lang, src, out)
53 return out53 return out
54 }54 }
@@ -110,6 +110,66 @@ func LanguageFromPath(p string) string {
110 110
111 // chooseLexer picks the best lexer for a language hint; falls back to111 // chooseLexer picks the best lexer for a language hint; falls back to
112 // nil (no highlighting) if the hint is empty or unknown.112 // nil (no highlighting) if the hint is empty or unknown.
113+// stripANSIBackgrounds removes SGR background-color attributes emitted by
114+// terminal syntax formatters while preserving foreground colors and styles.
115+// Chroma's inherited styles can assign black backgrounds to a few tokens
116+// (notably punctuation/error-ish spans), which looks like random black blocks
117+// inside zot's dark-gray tool boxes. Foreground color is enough for code.
118+func stripANSIBackgrounds(s string) string {
119+ var out strings.Builder
120+ out.Grow(len(s))
121+ for i := 0; i < len(s); {
122+ if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
123+ out.WriteByte(s[i])
124+ i++
125+ continue
126+ }
127+ j := i + 2
128+ for j < len(s) && s[j] != 'm' {
129+ j++
130+ }
131+ if j >= len(s) {
132+ out.WriteString(s[i:])
133+ break
134+ }
135+ params := strings.Split(s[i+2:j], ";")
136+ kept := make([]string, 0, len(params))
137+ for p := 0; p < len(params); p++ {
138+ param := params[p]
139+ switch param {
140+ case "48":
141+ // 48;5;N or 48;2;R;G;B background color.
142+ if p+1 < len(params) && params[p+1] == "5" {
143+ p += 2
144+ continue
145+ }
146+ if p+1 < len(params) && params[p+1] == "2" {
147+ p += 4
148+ continue
149+ }
150+ continue
151+ case "49":
152+ continue
153+ }
154+ // 40-47 and 100-107 are ANSI background colors.
155+ if len(param) == 2 && param[0] == '4' && param[1] >= '0' && param[1] <= '7' {
156+ continue
157+ }
158+ if len(param) == 3 && param[0] == '1' && param[1] == '0' && param[2] >= '0' && param[2] <= '7' {
159+ continue
160+ }
161+ kept = append(kept, param)
162+ }
163+ if len(kept) > 0 {
164+ out.WriteString("\x1b[")
165+ out.WriteString(strings.Join(kept, ";"))
166+ out.WriteByte('m')
167+ }
168+ i = j + 1
169+ }
170+ return out.String()
171+}
172+
113 func chooseLexer(lang string) chroma.Lexer {173 func chooseLexer(lang string) chroma.Lexer {
114 if lang == "" {174 if lang == "" {
115 return nil175 return nil

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 1

Tags 476

↑↓ moveenter openesc close