tui: tune user bubble colors
- commit
- 6ba41f29eff53060c2b0901b27edb9b62a00ab03
- author
- patriceckhart · · 2026-05-03 17:23 UTC
- parent
- a40d1cf
- signature
- unsigned
- refs
- tag: v0.1.46
3 files · +61 -16
M internal/agent/modes/btw_dialog.go +1 -1
| @@ -392,7 +392,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string { | |||
| 392 | 392 | if innerWidth < 1 { | |
| 393 | 393 | innerWidth = 1 | |
| 394 | 394 | } | |
| 395 | - | bar := th.BG256(th.UserBubbleBG, th.FG256(th.Accent, "▌ ")) | |
| 395 | + | bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ ")) | |
| 396 | 396 | row := func(content string) string { | |
| 397 | 397 | inner := strings.Repeat(" ", leftGutter) + content | |
| 398 | 398 | return " " + bar + th.UserBubble(inner, bubbleWidth-2) | |
| @@ -392,7 +392,7 @@ func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string { | |||||
| 392 | if innerWidth < 1 { | 392 | if innerWidth < 1 { | ||
| 393 | innerWidth = 1 | 393 | innerWidth = 1 | ||
| 394 | } | 394 | } | ||
| 395 | - | bar := th.BG256(th.UserBubbleBG, th.FG256(th.Accent, "▌ ")) | 395 | + | bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ ")) |
| 396 | row := func(content string) string { | 396 | row := func(content string) string { | ||
| 397 | inner := strings.Repeat(" ", leftGutter) + content | 397 | inner := strings.Repeat(" ", leftGutter) + content | ||
| 398 | return " " + bar + th.UserBubble(inner, bubbleWidth-2) | 398 | return " " + bar + th.UserBubble(inner, bubbleWidth-2) | ||
M internal/tui/theme.go +59 -14
| @@ -5,16 +5,44 @@ package tui | |||
| 5 | 5 | ||
| 6 | 6 | import "strings" | |
| 7 | 7 | ||
| 8 | + | // TerminalColor describes a terminal colour in one of the colour | |
| 9 | + | // spaces terminals commonly support. Most of zot's theme still uses | |
| 10 | + | // xterm-256 indexes, but user-bubble backgrounds can also use ANSI | |
| 11 | + | // theme slots (for example SGR 100 / bright-black background) so they | |
| 12 | + | // match the user's terminal theme rather than the fixed 256-colour | |
| 13 | + | // cube. | |
| 14 | + | type TerminalColor struct { | |
| 15 | + | Mode terminalColorMode | |
| 16 | + | Index int | |
| 17 | + | R int | |
| 18 | + | G int | |
| 19 | + | B int | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type terminalColorMode int | |
| 23 | + | ||
| 24 | + | const ( | |
| 25 | + | terminalColor256 terminalColorMode = iota | |
| 26 | + | terminalColorANSI | |
| 27 | + | terminalColorRGB | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} } | |
| 31 | + | func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} } | |
| 32 | + | func ColorRGB(r, g, b int) TerminalColor { | |
| 33 | + | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | |
| 34 | + | } | |
| 35 | + | ||
| 8 | 36 | // ANSI 256-color palette used by zot. Defined as numeric codes so we | |
| 9 | 37 | // can swap themes without changing any render code. | |
| 10 | 38 | type Theme struct { | |
| 11 | 39 | FG int | |
| 12 | 40 | Muted int | |
| 13 | 41 | Accent int | |
| 14 | - | User int // label color for the user role | |
| 15 | - | UserBubbleBG int // background tint behind user message rows | |
| 16 | - | UserBubbleFG int // foreground colour for user message rows | |
| 17 | - | Assistant int // label color for the zot role | |
| 42 | + | User int // label color for the user role | |
| 43 | + | UserBubbleBG TerminalColor // background tint behind user message rows | |
| 44 | + | UserBubbleFG int // foreground colour for user message rows | |
| 45 | + | Assistant int // label color for the zot role | |
| 18 | 46 | Tool int | |
| 19 | 47 | ToolOut int | |
| 20 | 48 | Error int | |
| @@ -27,12 +55,12 @@ type Theme struct { | |||
| 27 | 55 | var Dark = Theme{ | |
| 28 | 56 | FG: 253, | |
| 29 | 57 | Muted: 244, | |
| 30 | - | Accent: 111, // soft blue | |
| 31 | - | User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat) | |
| 32 | - | UserBubbleBG: 237, // soft mid-dark grey panel behind user rows | |
| 33 | - | UserBubbleFG: 246, // matches Theme.Muted (status bar text colour) | |
| 34 | - | Assistant: 117, // bright cyan — the zot label color | |
| 35 | - | Tool: 114, // green | |
| 58 | + | Accent: 111, // soft blue | |
| 59 | + | User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat) | |
| 60 | + | UserBubbleBG: ColorRGB(66, 69, 75), // #42454B | |
| 61 | + | UserBubbleFG: 248, // slightly lighter grey for readability on #42454B | |
| 62 | + | Assistant: 117, // bright cyan — the zot label color | |
| 63 | + | Tool: 114, // green | |
| 36 | 64 | ToolOut: 245, | |
| 37 | 65 | Error: 203, | |
| 38 | 66 | Warning: 214, | |
| @@ -46,9 +74,9 @@ var Light = Theme{ | |||
| 46 | 74 | Muted: 244, | |
| 47 | 75 | Accent: 33, | |
| 48 | 76 | User: 94, | |
| 49 | - | UserBubbleBG: 254, // very pale grey panel behind user rows on light theme | |
| 50 | - | UserBubbleFG: 240, // dark grey text, legible on the pale panel | |
| 51 | - | Assistant: 31, // deep cyan | |
| 77 | + | UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme | |
| 78 | + | UserBubbleFG: 240, // dark grey text, legible on the pale panel | |
| 79 | + | Assistant: 31, // deep cyan | |
| 52 | 80 | Tool: 28, | |
| 53 | 81 | ToolOut: 240, | |
| 54 | 82 | Error: 160, | |
| @@ -71,6 +99,13 @@ func (t Theme) BG256(c int, s string) string { | |||
| 71 | 99 | return sgrBG(c) + s + reset | |
| 72 | 100 | } | |
| 73 | 101 | ||
| 102 | + | // BG wraps s in a terminal background colour. Unlike BG256, this can | |
| 103 | + | // target ANSI theme slots or truecolor RGB, which lets selected UI | |
| 104 | + | // surfaces follow the user's terminal theme. | |
| 105 | + | func (t Theme) BG(c TerminalColor, s string) string { | |
| 106 | + | return sgrBGColor(c) + s + reset | |
| 107 | + | } | |
| 108 | + | ||
| 74 | 109 | // AccentBar returns a 2-cell-wide leader: a coloured half-block | |
| 75 | 110 | // glyph followed by a plain space gutter. Used as the speaker-label | |
| 76 | 111 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | |
| @@ -106,7 +141,7 @@ func (t Theme) UserBubble(s string, width int) string { | |||
| 106 | 141 | if visible < width { | |
| 107 | 142 | s += strings.Repeat(" ", width-visible) | |
| 108 | 143 | } | |
| 109 | - | return sgrFG(t.UserBubbleFG) + sgrBG(t.UserBubbleBG) + s + reset | |
| 144 | + | return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset | |
| 110 | 145 | } | |
| 111 | 146 | ||
| 112 | 147 | // UserBubbleRow renders one user-bubble row prefixed with a coloured | |
| @@ -140,6 +175,16 @@ const reset = "\x1b[0m" | |||
| 140 | 175 | ||
| 141 | 176 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" } | |
| 142 | 177 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" } | |
| 178 | + | func sgrBGColor(c TerminalColor) string { | |
| 179 | + | switch c.Mode { | |
| 180 | + | case terminalColorANSI: | |
| 181 | + | return "\x1b[" + itoa(c.Index) + "m" | |
| 182 | + | case terminalColorRGB: | |
| 183 | + | return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m" | |
| 184 | + | default: | |
| 185 | + | return sgrBG(c.Index) | |
| 186 | + | } | |
| 187 | + | } | |
| 143 | 188 | ||
| 144 | 189 | // small itoa to avoid pulling strconv into this hot path twice. | |
| 145 | 190 | func itoa(n int) string { | |
| @@ -5,16 +5,44 @@ package tui | |||||
| 5 | 5 | ||||
| 6 | import "strings" | 6 | import "strings" | ||
| 7 | 7 | ||||
| 8 | + | // TerminalColor describes a terminal colour in one of the colour | |||
| 9 | + | // spaces terminals commonly support. Most of zot's theme still uses | |||
| 10 | + | // xterm-256 indexes, but user-bubble backgrounds can also use ANSI | |||
| 11 | + | // theme slots (for example SGR 100 / bright-black background) so they | |||
| 12 | + | // match the user's terminal theme rather than the fixed 256-colour | |||
| 13 | + | // cube. | |||
| 14 | + | type TerminalColor struct { | |||
| 15 | + | Mode terminalColorMode | |||
| 16 | + | Index int | |||
| 17 | + | R int | |||
| 18 | + | G int | |||
| 19 | + | B int | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type terminalColorMode int | |||
| 23 | + | ||||
| 24 | + | const ( | |||
| 25 | + | terminalColor256 terminalColorMode = iota | |||
| 26 | + | terminalColorANSI | |||
| 27 | + | terminalColorRGB | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} } | |||
| 31 | + | func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} } | |||
| 32 | + | func ColorRGB(r, g, b int) TerminalColor { | |||
| 33 | + | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | |||
| 34 | + | } | |||
| 35 | + | ||||
| 8 | // ANSI 256-color palette used by zot. Defined as numeric codes so we | 36 | // ANSI 256-color palette used by zot. Defined as numeric codes so we | ||
| 9 | // can swap themes without changing any render code. | 37 | // can swap themes without changing any render code. | ||
| 10 | type Theme struct { | 38 | type Theme struct { | ||
| 11 | FG int | 39 | FG int | ||
| 12 | Muted int | 40 | Muted int | ||
| 13 | Accent int | 41 | Accent int | ||
| 14 | - | User int // label color for the user role | 42 | + | User int // label color for the user role |
| 15 | - | UserBubbleBG int // background tint behind user message rows | 43 | + | UserBubbleBG TerminalColor // background tint behind user message rows |
| 16 | - | UserBubbleFG int // foreground colour for user message rows | 44 | + | UserBubbleFG int // foreground colour for user message rows |
| 17 | - | Assistant int // label color for the zot role | 45 | + | Assistant int // label color for the zot role |
| 18 | Tool int | 46 | Tool int | ||
| 19 | ToolOut int | 47 | ToolOut int | ||
| 20 | Error int | 48 | Error int | ||
| @@ -27,12 +55,12 @@ type Theme struct { | |||||
| 27 | var Dark = Theme{ | 55 | var Dark = Theme{ | ||
| 28 | FG: 253, | 56 | FG: 253, | ||
| 29 | Muted: 244, | 57 | Muted: 244, | ||
| 30 | - | Accent: 111, // soft blue | 58 | + | Accent: 111, // soft blue |
| 31 | - | User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat) | 59 | + | User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat) |
| 32 | - | UserBubbleBG: 237, // soft mid-dark grey panel behind user rows | 60 | + | UserBubbleBG: ColorRGB(66, 69, 75), // #42454B |
| 33 | - | UserBubbleFG: 246, // matches Theme.Muted (status bar text colour) | 61 | + | UserBubbleFG: 248, // slightly lighter grey for readability on #42454B |
| 34 | - | Assistant: 117, // bright cyan — the zot label color | 62 | + | Assistant: 117, // bright cyan — the zot label color |
| 35 | - | Tool: 114, // green | 63 | + | Tool: 114, // green |
| 36 | ToolOut: 245, | 64 | ToolOut: 245, | ||
| 37 | Error: 203, | 65 | Error: 203, | ||
| 38 | Warning: 214, | 66 | Warning: 214, | ||
| @@ -46,9 +74,9 @@ var Light = Theme{ | |||||
| 46 | Muted: 244, | 74 | Muted: 244, | ||
| 47 | Accent: 33, | 75 | Accent: 33, | ||
| 48 | User: 94, | 76 | User: 94, | ||
| 49 | - | UserBubbleBG: 254, // very pale grey panel behind user rows on light theme | 77 | + | UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme |
| 50 | - | UserBubbleFG: 240, // dark grey text, legible on the pale panel | 78 | + | UserBubbleFG: 240, // dark grey text, legible on the pale panel |
| 51 | - | Assistant: 31, // deep cyan | 79 | + | Assistant: 31, // deep cyan |
| 52 | Tool: 28, | 80 | Tool: 28, | ||
| 53 | ToolOut: 240, | 81 | ToolOut: 240, | ||
| 54 | Error: 160, | 82 | Error: 160, | ||
| @@ -71,6 +99,13 @@ func (t Theme) BG256(c int, s string) string { | |||||
| 71 | return sgrBG(c) + s + reset | 99 | return sgrBG(c) + s + reset | ||
| 72 | } | 100 | } | ||
| 73 | 101 | ||||
| 102 | + | // BG wraps s in a terminal background colour. Unlike BG256, this can | |||
| 103 | + | // target ANSI theme slots or truecolor RGB, which lets selected UI | |||
| 104 | + | // surfaces follow the user's terminal theme. | |||
| 105 | + | func (t Theme) BG(c TerminalColor, s string) string { | |||
| 106 | + | return sgrBGColor(c) + s + reset | |||
| 107 | + | } | |||
| 108 | + | ||||
| 74 | // AccentBar returns a 2-cell-wide leader: a coloured half-block | 109 | // AccentBar returns a 2-cell-wide leader: a coloured half-block | ||
| 75 | // glyph followed by a plain space gutter. Used as the speaker-label | 110 | // glyph followed by a plain space gutter. Used as the speaker-label | ||
| 76 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | 111 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | ||
| @@ -106,7 +141,7 @@ func (t Theme) UserBubble(s string, width int) string { | |||||
| 106 | if visible < width { | 141 | if visible < width { | ||
| 107 | s += strings.Repeat(" ", width-visible) | 142 | s += strings.Repeat(" ", width-visible) | ||
| 108 | } | 143 | } | ||
| 109 | - | return sgrFG(t.UserBubbleFG) + sgrBG(t.UserBubbleBG) + s + reset | 144 | + | return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset |
| 110 | } | 145 | } | ||
| 111 | 146 | ||||
| 112 | // UserBubbleRow renders one user-bubble row prefixed with a coloured | 147 | // UserBubbleRow renders one user-bubble row prefixed with a coloured | ||
| @@ -140,6 +175,16 @@ const reset = "\x1b[0m" | |||||
| 140 | 175 | ||||
| 141 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" } | 176 | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" } | ||
| 142 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" } | 177 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" } | ||
| 178 | + | func sgrBGColor(c TerminalColor) string { | |||
| 179 | + | switch c.Mode { | |||
| 180 | + | case terminalColorANSI: | |||
| 181 | + | return "\x1b[" + itoa(c.Index) + "m" | |||
| 182 | + | case terminalColorRGB: | |||
| 183 | + | return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m" | |||
| 184 | + | default: | |||
| 185 | + | return sgrBG(c.Index) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 143 | 188 | ||||
| 144 | // small itoa to avoid pulling strconv into this hot path twice. | 189 | // small itoa to avoid pulling strconv into this hot path twice. | ||
| 145 | func itoa(n int) string { | 190 | func itoa(n int) string { | ||
M internal/tui/view.go +1 -1
| @@ -489,7 +489,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 489 | 489 | row := func(content string) string { | |
| 490 | 490 | inner := strings.Repeat(" ", leftGutter) + content | |
| 491 | 491 | padded := v.Theme.UserBubble(inner, width-2) | |
| 492 | - | bar := v.Theme.BG256(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 492 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 493 | 493 | return bar + padded | |
| 494 | 494 | } | |
| 495 | 495 | var bubble []string | |
| @@ -489,7 +489,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 489 | row := func(content string) string { | 489 | row := func(content string) string { | ||
| 490 | inner := strings.Repeat(" ", leftGutter) + content | 490 | inner := strings.Repeat(" ", leftGutter) + content | ||
| 491 | padded := v.Theme.UserBubble(inner, width-2) | 491 | padded := v.Theme.UserBubble(inner, width-2) | ||
| 492 | - | bar := v.Theme.BG256(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | 492 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) |
| 493 | return bar + padded | 493 | return bar + padded | ||
| 494 | } | 494 | } | ||
| 495 | var bubble []string | 495 | var bubble []string | ||