detached b05aef5

Merge pull request #27 from bnema/codex/support-colon-sgr-colors

[codex] support colon SGR colors
commit
b05aef55df1a29139a1438a770652129ddf574ac
author
bnema · · 2026-07-06 05:53 UTC
committer
GitHub · · 2026-07-06 05:53 UTC
merge of
74411ec + dc894d2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +162 -7
M pkg/vt/screen.go +55 -7
@@ -952,7 +952,7 @@ func (s *Screen) applySGR(params string) {
952952 s.Style = renderer.DefaultStyle()
953953 return
954954 }
955- parts := parseCSIInts(params)
955+ parts := parseSGRInts(params)
956956 for i := 0; i < len(parts); i++ {
957957 switch parts[i] {
958958 case 0:
@@ -1314,20 +1314,68 @@ func parseCSIInts(params string) []int {
13141314 parts := strings.Split(params, ";")
13151315 out := make([]int, 0, len(parts))
13161316 for _, p := range parts {
1317- if p == "" {
1318- out = append(out, 0)
1317+ out = append(out, parseCSIInt(p))
1318+ }
1319+ return out
1320+}
1321+
1322+func parseSGRInts(params string) []int {
1323+ if params == "" {
1324+ return nil
1325+ }
1326+ groups := strings.Split(params, ";")
1327+ out := make([]int, 0, len(groups))
1328+ for _, group := range groups {
1329+ if !strings.Contains(group, ":") {
1330+ out = append(out, parseCSIInt(group))
13191331 continue
13201332 }
1321- v, err := strconv.Atoi(p)
1322- if err != nil {
1323- out = append(out, 0)
1333+ sub := strings.Split(group, ":")
1334+ code := parseCSIInt(sub[0])
1335+ if code != 38 && code != 48 {
1336+ out = append(out, code)
13241337 continue
13251338 }
1326- out = append(out, v)
1339+ mode := 0
1340+ if len(sub) > 1 {
1341+ mode = parseCSIInt(sub[1])
1342+ }
1343+ switch mode {
1344+ case 5:
1345+ if len(sub) < 3 {
1346+ continue
1347+ }
1348+ out = append(out, code, mode, parseCSIInt(sub[2]))
1349+ case 2:
1350+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1351+ // the colorspace slot; code:mode:R:G:B omits that slot.
1352+ start := 2
1353+ if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) {
1354+ start = 3
1355+ }
1356+ if len(sub) < start+3 {
1357+ continue
1358+ }
1359+ out = append(out, code, mode)
1360+ for i := 0; i < 3; i++ {
1361+ out = append(out, parseCSIInt(sub[start+i]))
1362+ }
1363+ }
13271364 }
13281365 return out
13291366 }
13301367
1368+func parseCSIInt(param string) int {
1369+ if param == "" {
1370+ return 0
1371+ }
1372+ v, err := strconv.Atoi(param)
1373+ if err != nil {
1374+ return 0
1375+ }
1376+ return v
1377+}
1378+
13311379 func clamp(v, lo, hi int) int {
13321380 if hi < lo {
13331381 return lo
@@ -952,7 +952,7 @@ func (s *Screen) applySGR(params string) {
952 s.Style = renderer.DefaultStyle()952 s.Style = renderer.DefaultStyle()
953 return953 return
954 }954 }
955- parts := parseCSIInts(params)955+ parts := parseSGRInts(params)
956 for i := 0; i < len(parts); i++ {956 for i := 0; i < len(parts); i++ {
957 switch parts[i] {957 switch parts[i] {
958 case 0:958 case 0:
@@ -1314,20 +1314,68 @@ func parseCSIInts(params string) []int {
1314 parts := strings.Split(params, ";")1314 parts := strings.Split(params, ";")
1315 out := make([]int, 0, len(parts))1315 out := make([]int, 0, len(parts))
1316 for _, p := range parts {1316 for _, p := range parts {
1317- if p == "" {1317+ out = append(out, parseCSIInt(p))
1318- out = append(out, 0)1318+ }
1319+ return out
1320+}
1321+
1322+func parseSGRInts(params string) []int {
1323+ if params == "" {
1324+ return nil
1325+ }
1326+ groups := strings.Split(params, ";")
1327+ out := make([]int, 0, len(groups))
1328+ for _, group := range groups {
1329+ if !strings.Contains(group, ":") {
1330+ out = append(out, parseCSIInt(group))
1319 continue1331 continue
1320 }1332 }
1321- v, err := strconv.Atoi(p)1333+ sub := strings.Split(group, ":")
1322- if err != nil {1334+ code := parseCSIInt(sub[0])
1323- out = append(out, 0)1335+ if code != 38 && code != 48 {
1336+ out = append(out, code)
1324 continue1337 continue
1325 }1338 }
1326- out = append(out, v)1339+ mode := 0
1340+ if len(sub) > 1 {
1341+ mode = parseCSIInt(sub[1])
1342+ }
1343+ switch mode {
1344+ case 5:
1345+ if len(sub) < 3 {
1346+ continue
1347+ }
1348+ out = append(out, code, mode, parseCSIInt(sub[2]))
1349+ case 2:
1350+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1351+ // the colorspace slot; code:mode:R:G:B omits that slot.
1352+ start := 2
1353+ if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) {
1354+ start = 3
1355+ }
1356+ if len(sub) < start+3 {
1357+ continue
1358+ }
1359+ out = append(out, code, mode)
1360+ for i := 0; i < 3; i++ {
1361+ out = append(out, parseCSIInt(sub[start+i]))
1362+ }
1363+ }
1327 }1364 }
1328 return out1365 return out
1329 }1366 }
1330 1367
1368+func parseCSIInt(param string) int {
1369+ if param == "" {
1370+ return 0
1371+ }
1372+ v, err := strconv.Atoi(param)
1373+ if err != nil {
1374+ return 0
1375+ }
1376+ return v
1377+}
1378+
1331 func clamp(v, lo, hi int) int {1379 func clamp(v, lo, hi int) int {
1332 if hi < lo {1380 if hi < lo {
1333 return lo1381 return lo
M pkg/vt/screen_test.go +107 -0
@@ -738,6 +738,67 @@ func TestSGRTruecolor(t *testing.T) {
738738 }
739739 },
740740 },
741+ {
742+ name: "SGR 38:2 sets truecolor foreground",
743+ seq: "\x1b[38:2:12:34:56mX",
744+ check: func(t *testing.T, s *Screen) {
745+ want := renderer.RGB{R: 12, G: 34, B: 56}
746+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
747+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
748+ }
749+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
750+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
751+ }
752+ },
753+ },
754+ {
755+ name: "SGR 38:2 empty color space sets truecolor foreground",
756+ seq: "\x1b[38:2::12:34:56mX",
757+ check: func(t *testing.T, s *Screen) {
758+ want := renderer.RGB{R: 12, G: 34, B: 56}
759+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
760+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
761+ }
762+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
763+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
764+ }
765+ },
766+ },
767+ {
768+ name: "SGR 38:2 color space id sets truecolor foreground",
769+ seq: "\x1b[38:2:1:12:34:56mX",
770+ check: func(t *testing.T, s *Screen) {
771+ want := renderer.RGB{R: 12, G: 34, B: 56}
772+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
773+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
774+ }
775+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
776+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
777+ }
778+ },
779+ },
780+ {
781+ name: "mixed semicolon and colon SGR groups",
782+ seq: "\x1b[1;38:2:10:20:30;7mX",
783+ check: func(t *testing.T, s *Screen) {
784+ want := renderer.RGB{R: 10, G: 20, B: 30}
785+ if !s.Style.Bold || !s.Style.Inverse {
786+ t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
787+ }
788+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
789+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
790+ }
791+ },
792+ },
793+ {
794+ name: "truncated colon truecolor group does not consume next parameter",
795+ seq: "\x1b[38:2:1:2;31m",
796+ check: func(t *testing.T, s *Screen) {
797+ if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
798+ t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
799+ }
800+ },
801+ },
741802 {
742803 name: "SGR 48;2 sets truecolor background",
743804 seq: "\x1b[48;2;200;100;50m",
@@ -748,6 +809,52 @@ func TestSGRTruecolor(t *testing.T) {
748809 }
749810 },
750811 },
812+ {
813+ name: "SGR 48:2 sets truecolor background",
814+ seq: "\x1b[48:2:200:100:50m",
815+ check: func(t *testing.T, s *Screen) {
816+ want := renderer.RGB{R: 200, G: 100, B: 50}
817+ if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
818+ t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
819+ }
820+ },
821+ },
822+ {
823+ name: "SGR 38:5 sets 256-color foreground",
824+ seq: "\x1b[38:5:82m",
825+ check: func(t *testing.T, s *Screen) {
826+ if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
827+ t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
828+ }
829+ },
830+ },
831+ {
832+ name: "SGR 48:5 sets 256-color background",
833+ seq: "\x1b[48:5:200m",
834+ check: func(t *testing.T, s *Screen) {
835+ if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
836+ t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
837+ }
838+ },
839+ },
840+ {
841+ name: "truncated colon foreground index group does not consume next parameter",
842+ seq: "\x1b[38:5;31m",
843+ check: func(t *testing.T, s *Screen) {
844+ if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
845+ t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
846+ }
847+ },
848+ },
849+ {
850+ name: "truncated colon background index group does not consume next parameter",
851+ seq: "\x1b[48:5;44m",
852+ check: func(t *testing.T, s *Screen) {
853+ if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
854+ t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
855+ }
856+ },
857+ },
751858 {
752859 name: "SGR 39 after truecolor clears truecolor foreground",
753860 seq: "\x1b[38;2;12;34;56m\x1b[39m",
@@ -738,6 +738,67 @@ func TestSGRTruecolor(t *testing.T) {
738 }738 }
739 },739 },
740 },740 },
741+ {
742+ name: "SGR 38:2 sets truecolor foreground",
743+ seq: "\x1b[38:2:12:34:56mX",
744+ check: func(t *testing.T, s *Screen) {
745+ want := renderer.RGB{R: 12, G: 34, B: 56}
746+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
747+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
748+ }
749+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
750+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
751+ }
752+ },
753+ },
754+ {
755+ name: "SGR 38:2 empty color space sets truecolor foreground",
756+ seq: "\x1b[38:2::12:34:56mX",
757+ check: func(t *testing.T, s *Screen) {
758+ want := renderer.RGB{R: 12, G: 34, B: 56}
759+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
760+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
761+ }
762+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
763+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
764+ }
765+ },
766+ },
767+ {
768+ name: "SGR 38:2 color space id sets truecolor foreground",
769+ seq: "\x1b[38:2:1:12:34:56mX",
770+ check: func(t *testing.T, s *Screen) {
771+ want := renderer.RGB{R: 12, G: 34, B: 56}
772+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
773+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
774+ }
775+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
776+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
777+ }
778+ },
779+ },
780+ {
781+ name: "mixed semicolon and colon SGR groups",
782+ seq: "\x1b[1;38:2:10:20:30;7mX",
783+ check: func(t *testing.T, s *Screen) {
784+ want := renderer.RGB{R: 10, G: 20, B: 30}
785+ if !s.Style.Bold || !s.Style.Inverse {
786+ t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
787+ }
788+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
789+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
790+ }
791+ },
792+ },
793+ {
794+ name: "truncated colon truecolor group does not consume next parameter",
795+ seq: "\x1b[38:2:1:2;31m",
796+ check: func(t *testing.T, s *Screen) {
797+ if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
798+ t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
799+ }
800+ },
801+ },
741 {802 {
742 name: "SGR 48;2 sets truecolor background",803 name: "SGR 48;2 sets truecolor background",
743 seq: "\x1b[48;2;200;100;50m",804 seq: "\x1b[48;2;200;100;50m",
@@ -748,6 +809,52 @@ func TestSGRTruecolor(t *testing.T) {
748 }809 }
749 },810 },
750 },811 },
812+ {
813+ name: "SGR 48:2 sets truecolor background",
814+ seq: "\x1b[48:2:200:100:50m",
815+ check: func(t *testing.T, s *Screen) {
816+ want := renderer.RGB{R: 200, G: 100, B: 50}
817+ if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
818+ t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
819+ }
820+ },
821+ },
822+ {
823+ name: "SGR 38:5 sets 256-color foreground",
824+ seq: "\x1b[38:5:82m",
825+ check: func(t *testing.T, s *Screen) {
826+ if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
827+ t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
828+ }
829+ },
830+ },
831+ {
832+ name: "SGR 48:5 sets 256-color background",
833+ seq: "\x1b[48:5:200m",
834+ check: func(t *testing.T, s *Screen) {
835+ if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
836+ t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
837+ }
838+ },
839+ },
840+ {
841+ name: "truncated colon foreground index group does not consume next parameter",
842+ seq: "\x1b[38:5;31m",
843+ check: func(t *testing.T, s *Screen) {
844+ if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
845+ t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
846+ }
847+ },
848+ },
849+ {
850+ name: "truncated colon background index group does not consume next parameter",
851+ seq: "\x1b[48:5;44m",
852+ check: func(t *testing.T, s *Screen) {
853+ if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
854+ t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
855+ }
856+ },
857+ },
751 {858 {
752 name: "SGR 39 after truecolor clears truecolor foreground",859 name: "SGR 39 after truecolor clears truecolor foreground",
753 seq: "\x1b[38;2;12;34;56m\x1b[39m",860 seq: "\x1b[38;2;12;34;56m\x1b[39m",

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