@@ -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+}