on main help sign in
feat(bang): remap ansi 16 colors
1c2da89Kieran Klukas
internal/ui/chat/ansi16.gonew file+128
@@ -0,0 +1,128 @@
1+package chat
2+
3+import (
4+ "image/color"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/charmbracelet/x/ansi"
9+)
10+
11+// remapANSI16 replaces basic ANSI 16-color SGR codes with 24-bit
12+// truecolor from palette. Programs emit \x1b[31m etc. and trust the
13+// terminal to pick the color; inside Crush's TUI those defaults are
14+// often illegible on our dark background. Rewriting them to explicit
15+// RGB keeps output readable regardless of terminal configuration.
16+//
17+// Uses [ansi.DecodeSequence] for parsing (same approach as
18+// [colorprofile.Writer]) since there is no upstream palette-remap API.
19+func remapANSI16(s string, palette [16]color.Color) string {
20+ if !strings.ContainsRune(s, 0x1b) {
21+ return s
22+ }
23+
24+ var buf strings.Builder
25+ buf.Grow(len(s))
26+
27+ parser := ansi.GetParser()
28+ defer ansi.PutParser(parser)
29+
30+ var state byte
31+ for len(s) > 0 {
32+ parser.Reset()
33+ seq, _, n, newState := ansi.DecodeSequence(s, state, parser)
34+
35+ if ansi.HasCsiPrefix(seq) && parser.Command() == 'm' {
36+ remapSGR(parser.Params(), palette, &buf)
37+ } else {
38+ buf.WriteString(seq)
39+ }
40+
41+ s = s[n:]
42+ state = newState
43+ }
44+
45+ return buf.String()
46+}
47+
48+// remapSGR rewrites one SGR sequence, replacing 16-color params with
49+// truecolor from palette. Extended colors (38/48/58 with ;5;n or
50+// ;2;r;g;b sub-params) pass through unchanged. Non-color attributes
51+// (bold, italic, etc.) and default color resets (39/49/59) also pass
52+// through.
53+func remapSGR(params ansi.Params, palette [16]color.Color, buf *strings.Builder) {
54+ buf.WriteString("\x1b[")
55+
56+ first := true
57+ for i := 0; i < len(params); i++ {
58+ p := params[i].Param(0)
59+
60+ if !first {
61+ buf.WriteByte(';')
62+ }
63+ first = false
64+
65+ switch {
66+ // Extended color introducers consume subsequent params as
67+ // arguments. Skip them whole so they aren't misread.
68+ case p == 38 || p == 48 || p == 58:
69+ buf.WriteString(strconv.Itoa(p))
70+ if i+1 < len(params) {
71+ sub := params[i+1].Param(0)
72+ switch sub {
73+ case 5: // 256-color: 38;5;n
74+ buf.WriteByte(';')
75+ buf.WriteString(strconv.Itoa(sub))
76+ if i+2 < len(params) {
77+ buf.WriteByte(';')
78+ buf.WriteString(strconv.Itoa(params[i+2].Param(0)))
79+ i += 2
80+ } else {
81+ i++
82+ }
83+ case 2: // truecolor: 38;2;r;g;b
84+ buf.WriteByte(';')
85+ buf.WriteString(strconv.Itoa(sub))
86+ for j := 2; j <= 4 && i+j < len(params); j++ {
87+ buf.WriteByte(';')
88+ buf.WriteString(strconv.Itoa(params[i+j].Param(0)))
89+ }
90+ i += min(4, len(params)-i-1)
91+ default:
92+ i++
93+ }
94+ }
95+
96+ case p >= 30 && p <= 37:
97+ writeTruecolor(buf, 38, palette[p-30])
98+ case p >= 90 && p <= 97:
99+ writeTruecolor(buf, 38, palette[8+p-90])
100+ case p >= 40 && p <= 47:
101+ writeTruecolor(buf, 48, palette[p-40])
102+ case p >= 100 && p <= 107:
103+ writeTruecolor(buf, 48, palette[8+p-100])
104+
105+ default:
106+ buf.WriteString(strconv.Itoa(p))
107+ }
108+ }
109+
110+ buf.WriteByte('m')
111+}
112+
113+// writeTruecolor appends "introducer;2;r;g;b" to buf. Nil color emits
114+// the bare introducer so the terminal default applies.
115+func writeTruecolor(buf *strings.Builder, introducer int, c color.Color) {
116+ if c == nil {
117+ buf.WriteString(strconv.Itoa(introducer))
118+ return
119+ }
120+ r, g, b, _ := c.RGBA()
121+ buf.WriteString(strconv.Itoa(introducer))
122+ buf.WriteString(";2;")
123+ buf.WriteString(strconv.Itoa(int(r >> 8)))
124+ buf.WriteByte(';')
125+ buf.WriteString(strconv.Itoa(int(g >> 8)))
126+ buf.WriteByte(';')
127+ buf.WriteString(strconv.Itoa(int(b >> 8)))
128+}
internal/ui/chat/ansi16_preview_test.gonew file+45
@@ -0,0 +1,45 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "testing"
7+)
8+
9+// TestPreviewANSI16Palette prints a swatch grid using the raw ANSI 16
10+// color codes (the codes terminal programs actually emit). It is not an
11+// assertion test; it exists so you can eyeball how the codes render:
12+//
13+// go test ./internal/ui/chat/ -run TestPreviewANSI16Palette -v
14+//
15+// These are the unmodified SGR codes, so what you see is your terminal's
16+// own palette, not Crush's remap.
17+func TestPreviewANSI16Palette(t *testing.T) {
18+ names := []string{
19+ "black", "red", "green", "yellow",
20+ "blue", "magenta", "cyan", "white",
21+ }
22+
23+ fmt.Println("\nForeground (normal | bright):")
24+ for i, name := range names {
25+ normal := "\x1b[" + strconv.Itoa(30+i) + "m" + pad(name) + "\x1b[0m"
26+ bright := "\x1b[" + strconv.Itoa(90+i) + "m" + pad("bright "+name) + "\x1b[0m"
27+ fmt.Printf(" %s %s\n", normal, bright)
28+ }
29+
30+ fmt.Println("\nBackground (normal | bright):")
31+ for i, name := range names {
32+ normal := "\x1b[30;" + strconv.Itoa(40+i) + "m" + pad(name) + "\x1b[0m"
33+ bright := "\x1b[30;" + strconv.Itoa(100+i) + "m" + pad("bright "+name) + "\x1b[0m"
34+ fmt.Printf(" %s %s\n", normal, bright)
35+ }
36+ fmt.Println()
37+}
38+
39+func pad(s string) string {
40+ const w = 16
41+ for len(s) < w {
42+ s += " "
43+ }
44+ return " " + s
45+}
internal/ui/chat/ansi16_test.gonew file+106
@@ -0,0 +1,106 @@
1+package chat
2+
3+import (
4+ "image/color"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func testPalette() [16]color.Color {
11+ var p [16]color.Color
12+ for i := range p {
13+ // Distinct, easy-to-assert colors: R=index, G=0, B=0.
14+ p[i] = color.RGBA{R: uint8(i), G: 0, B: 0, A: 0xFF}
15+ }
16+ return p
17+}
18+
19+func TestRemapANSI16(t *testing.T) {
20+ t.Parallel()
21+
22+ pal := testPalette()
23+
24+ tests := []struct {
25+ name string
26+ in string
27+ want string
28+ }{
29+ {
30+ name: "no escapes passes through",
31+ in: "plain text",
32+ want: "plain text",
33+ },
34+ {
35+ name: "standard foreground red is remapped",
36+ in: "\x1b[31mhi\x1b[0m",
37+ want: "\x1b[38;2;1;0;0mhi\x1b[0m",
38+ },
39+ {
40+ name: "bright foreground red is remapped",
41+ in: "\x1b[91mhi\x1b[0m",
42+ want: "\x1b[38;2;9;0;0mhi\x1b[0m",
43+ },
44+ {
45+ name: "standard background green is remapped",
46+ in: "\x1b[42mx\x1b[0m",
47+ want: "\x1b[48;2;2;0;0mx\x1b[0m",
48+ },
49+ {
50+ name: "bold plus color keeps bold, remaps color",
51+ in: "\x1b[1;31mx\x1b[0m",
52+ want: "\x1b[1;38;2;1;0;0mx\x1b[0m",
53+ },
54+ {
55+ name: "256-color extended is left untouched",
56+ in: "\x1b[38;5;196mx\x1b[0m",
57+ want: "\x1b[38;5;196mx\x1b[0m",
58+ },
59+ {
60+ name: "truecolor extended is left untouched",
61+ in: "\x1b[38;2;10;20;30mx\x1b[0m",
62+ want: "\x1b[38;2;10;20;30mx\x1b[0m",
63+ },
64+ {
65+ name: "non-SGR CSI sequence untouched",
66+ in: "\x1b[2J\x1b[31mx",
67+ want: "\x1b[2J\x1b[38;2;1;0;0mx",
68+ },
69+ {
70+ name: "reset and default fg left as-is",
71+ in: "\x1b[0;39mx",
72+ want: "\x1b[0;39mx",
73+ },
74+ {
75+ name: "underline 256-color extended is left untouched",
76+ in: "\x1b[58;5;196mx\x1b[0m",
77+ want: "\x1b[58;5;196mx\x1b[0m",
78+ },
79+ {
80+ name: "underline truecolor extended is left untouched",
81+ in: "\x1b[58;2;10;20;30mx\x1b[0m",
82+ want: "\x1b[58;2;10;20;30mx\x1b[0m",
83+ },
84+ {
85+ name: "default underline color left as-is",
86+ in: "\x1b[59mx",
87+ want: "\x1b[59mx",
88+ },
89+ }
90+
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ t.Parallel()
94+ require.Equal(t, tt.want, remapANSI16(tt.in, pal))
95+ })
96+ }
97+}
98+
99+func TestRemapANSI16NilColorFallsBack(t *testing.T) {
100+ t.Parallel()
101+
102+ var pal [16]color.Color // all nil
103+ // With a nil palette entry, the introducer is emitted bare so the
104+ // terminal default applies rather than crashing.
105+ require.Equal(t, "\x1b[38mx\x1b[0m", remapANSI16("\x1b[31mx\x1b[0m", pal))
106+}
internal/ui/chat/shell.go+4−1
@@ -217,7 +217,10 @@ func (s *ShellItem) RawRender(width int) string {
217 return header
217 return header
218 }
218 }
219
219
220+ // Remap raw ANSI 16-color codes onto legible Charmtone colors so
221+ // dark terminal defaults don't render illegibly on Crush's
222+ // background.
220− output := strings.TrimRight(s.output, "\n")
223+ output := remapANSI16(strings.TrimRight(s.output, "\n"), s.sty.ANSI)
221 lines := strings.Split(output, "\n")
224 lines := strings.Split(output, "\n")
222
225
223 // While streaming, show the tail of the output so the most recent
226 // While streaming, show the tail of the output so the most recent
internal/ui/chat/tools.go+1−1
@@ -621,7 +621,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
621
621
622// toolOutputPlainContent renders plain text with optional expansion support.
622// toolOutputPlainContent renders plain text with optional expansion support.
623func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
623func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
624− content = stringext.NormalizeSpace(content)
624+ content = remapANSI16(stringext.NormalizeSpace(content), sty.ANSI)
625 lines := strings.Split(content, "\n")
625 lines := strings.Split(content, "\n")
626
626
627 maxLines := responseContextHeight
627 maxLines := responseContextHeight
internal/ui/styles/quickstyle.go+32
@@ -55,6 +55,28 @@ type quickStyleOpts struct {
55 success color.Color
55 success color.Color
56 successMoreSubtle color.Color
56 successMoreSubtle color.Color
57 successMostSubtle color.Color
57 successMostSubtle color.Color
58+
59+ // ANSI 16-color palette. These remap the basic terminal colors that
60+ // programs emit (e.g. bang-mode shell output) onto legible, on-brand
61+ // colors instead of leaving them to the user's terminal defaults.
62+ // Normal intensity.
63+ ansiBlack color.Color
64+ ansiRed color.Color
65+ ansiGreen color.Color
66+ ansiYellow color.Color
67+ ansiBlue color.Color
68+ ansiMagenta color.Color
69+ ansiCyan color.Color
70+ ansiWhite color.Color
71+ // Bright intensity.
72+ ansiBrightBlack color.Color
73+ ansiBrightRed color.Color
74+ ansiBrightGreen color.Color
75+ ansiBrightYellow color.Color
76+ ansiBrightBlue color.Color
77+ ansiBrightMagenta color.Color
78+ ansiBrightCyan color.Color
79+ ansiBrightWhite color.Color
58}
80}
59
81
60// quickStyle builds the default Styles (that is, the default theme, Charmtone
82// quickStyle builds the default Styles (that is, the default theme, Charmtone
@@ -808,6 +830,16 @@ func quickStyle(o quickStyleOpts) Styles {
808 // No padding or border for compact tool calls within messages
830 // No padding or border for compact tool calls within messages
809 s.Messages.ToolCallCompact = muted
831 s.Messages.ToolCallCompact = muted
810
832
833+ // ANSI 16-color palette (indices 0-7 normal, 8-15 bright). Used to
834+ // remap raw terminal color codes in command output onto legible
835+ // colors. See [Styles.ANSI].
836+ s.ANSI = [16]color.Color{
837+ o.ansiBlack, o.ansiRed, o.ansiGreen, o.ansiYellow,
838+ o.ansiBlue, o.ansiMagenta, o.ansiCyan, o.ansiWhite,
839+ o.ansiBrightBlack, o.ansiBrightRed, o.ansiBrightGreen, o.ansiBrightYellow,
840+ o.ansiBrightBlue, o.ansiBrightMagenta, o.ansiBrightCyan, o.ansiBrightWhite,
841+ }
842+
811 // Shell (bang mode) item styles.
843 // Shell (bang mode) item styles.
812 s.Messages.ShellBarFocused = lipgloss.NewStyle().PaddingLeft(1).
844 s.Messages.ShellBarFocused = lipgloss.NewStyle().PaddingLeft(1).
813 BorderStyle(messageFocussedBorder).BorderLeft(true).
845 BorderStyle(messageFocussedBorder).BorderLeft(true).
internal/ui/styles/styles.go+10
@@ -60,6 +60,16 @@ const (
60)
60)
61
61
62type Styles struct {
62type Styles struct {
63+ // ANSI holds the 16 standard ANSI colors (0-7 normal, 8-15 bright)
64+ // used to remap legible colors onto raw terminal output, such as the
65+ // output of bang-mode shell commands. Terminal programs emit the
66+ // basic 16-color SGR codes (red, green, blue, …) and leave the actual
67+ // colors up to the terminal; without this palette they fall through
68+ // to the user's terminal defaults, which are often illegible on
69+ // Crush's background. Defining them here keeps output readable and
70+ // on-brand regardless of terminal configuration.
71+ ANSI [16]color.Color
72+
63 // Header
73 // Header
64 Header struct {
74 Header struct {
65 Charm lipgloss.Style // Style for "Charm™" label
75 Charm lipgloss.Style // Style for "Charm™" label
internal/ui/styles/themes.go+20
@@ -51,6 +51,26 @@ func CharmtonePantera() Styles {
51 success: charmtone.Julep,
51 success: charmtone.Julep,
52 successMoreSubtle: charmtone.Bok,
52 successMoreSubtle: charmtone.Bok,
53 successMostSubtle: charmtone.Guac,
53 successMostSubtle: charmtone.Guac,
54+
55+ // ANSI 16-color palette for remapping raw terminal output
56+ // (e.g. bang-mode shell commands) onto legible Charmtone colors.
57+ ansiBlack: charmtone.Pepper,
58+ ansiRed: charmtone.Coral,
59+ ansiGreen: charmtone.Guac,
60+ ansiYellow: charmtone.Mustard,
61+ ansiBlue: charmtone.Malibu,
62+ ansiMagenta: charmtone.Charple,
63+ ansiCyan: charmtone.Turtle,
64+ ansiWhite: charmtone.Smoke,
65+
66+ ansiBrightBlack: charmtone.Iron,
67+ ansiBrightRed: charmtone.Cherry,
68+ ansiBrightGreen: charmtone.Julep,
69+ ansiBrightYellow: charmtone.Citron,
70+ ansiBrightBlue: charmtone.Guppy,
71+ ansiBrightMagenta: charmtone.Violet,
72+ ansiBrightCyan: charmtone.Bok,
73+ ansiBrightWhite: charmtone.Salt,
54 })
74 })
55
75
56 // Bang ! prompt overrides - use Salt/Hazy/Larple colors.
76 // Bang ! prompt overrides - use Salt/Hazy/Larple colors.