From 974c4b61eafceb6eef10e9eda4b1af75bdc9bb6c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 17 Jun 2026 18:20:15 -0400 Subject: [PATCH] fix(tui): add ANSI-16 color fallbacks for SSH terminals Theme colors now use lipgloss.CompleteColor with hand-picked ANSI-16 values instead of raw hex. Prevents algorithmic degradation from collapsing dark backgrounds into indistinguishable ANSI colors over SSH. Backgrounds fall through to terminal default in 16-color mode; semantic colors map to distinct ANSI indices (green/yellow/red/blue/ cyan/magenta). TrueColor rendering is unchanged. --- internal/tui/sparkline.go | 32 +++-- internal/tui/sparkline_test.go | 36 +++--- internal/tui/tab_sites.go | 2 +- internal/tui/theme.go | 212 +++++++++++++++++---------------- internal/tui/tui.go | 12 +- internal/tui/view_detail.go | 4 +- 6 files changed, 160 insertions(+), 138 deletions(-) diff --git a/internal/tui/sparkline.go b/internal/tui/sparkline.go index 79b1fe6..11a3162 100644 --- a/internal/tui/sparkline.go +++ b/internal/tui/sparkline.go @@ -17,6 +17,17 @@ func parseHex(hex string) (r, g, b uint8) { return } +func trueColorHex(c lipgloss.TerminalColor) string { + switch v := c.(type) { + case lipgloss.CompleteColor: + return v.TrueColor + case lipgloss.Color: + return string(v) + default: + return "" + } +} + func dimColor(hex string, brightness float64) lipgloss.Color { r, g, b := parseHex(hex) f := 0.3 + brightness*0.7 @@ -27,35 +38,36 @@ func dimColor(hex string, brightness float64) lipgloss.Color { )) } -func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style { - if bg != "" { +func withBg(s lipgloss.Style, bg lipgloss.TerminalColor) lipgloss.Style { + if bg != nil { return s.Background(bg) } return s } -func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style { - var hex string +func (m Model) latencyStyle(ms int64, bg lipgloss.TerminalColor) lipgloss.Style { + var base lipgloss.TerminalColor var t float64 switch { case ms < 200: - hex = m.st.sparkSuccess + base = m.st.sparkSuccess t = float64(ms) / 200 case ms < 500: - hex = m.st.sparkWarning + base = m.st.sparkWarning t = float64(ms-200) / 300 default: - hex = m.st.sparkDanger + base = m.st.sparkDanger t = float64(ms-500) / 1500 if t > 1 { t = 1 } } + hex := trueColorHex(base) s := lipgloss.NewStyle().Foreground(dimColor(hex, t)) return withBg(s, bg) } -func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string { +func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.TerminalColor) string { if len(latencies) == 0 { return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) } @@ -103,7 +115,7 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt return sb.String() } -func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { +func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.TerminalColor) string { if len(statuses) == 0 { return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) } @@ -143,7 +155,7 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int { return offset + (x - padding) } -func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string { +func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string { allSites := m.engine.GetAllSites() var childStatuses [][]bool for _, s := range allSites { diff --git a/internal/tui/sparkline_test.go b/internal/tui/sparkline_test.go index 40ffe45..24af1c5 100644 --- a/internal/tui/sparkline_test.go +++ b/internal/tui/sparkline_test.go @@ -5,10 +5,12 @@ import ( "testing" "time" "unicode/utf8" + + "github.com/charmbracelet/lipgloss" ) func TestLatencySparkline_Empty(t *testing.T) { - got := styledModel.latencySparkline(nil, nil, 10, "") + got := styledModel.latencySparkline(nil, nil, 10, nil) if !strings.Contains(got, "··········") { t.Errorf("empty sparkline should be dots, got %q", got) } @@ -17,7 +19,7 @@ func TestLatencySparkline_Empty(t *testing.T) { func TestLatencySparkline_SingleValue(t *testing.T) { latencies := []time.Duration{100 * time.Millisecond} statuses := []bool{true} - got := styledModel.latencySparkline(latencies, statuses, 5, "") + got := styledModel.latencySparkline(latencies, statuses, 5, nil) if len(got) == 0 { t.Error("sparkline should not be empty") } @@ -33,7 +35,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) { latencies[i] = time.Duration(i*50) * time.Millisecond statuses[i] = true } - got := styledModel.latencySparkline(latencies, statuses, 5, "") + got := styledModel.latencySparkline(latencies, statuses, 5, nil) if len(got) == 0 { t.Error("sparkline should not be empty") } @@ -45,7 +47,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) { func TestLatencySparkline_RelativeHeight(t *testing.T) { latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond} statuses := []bool{true, true, true} - out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, "")) + out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, nil)) runes := []rune(out) if len(runes) < 3 { t.Fatalf("expected 3 runes, got %d", len(runes)) @@ -57,14 +59,14 @@ func TestLatencySparkline_RelativeHeight(t *testing.T) { func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) { st := newStyles(themeFlexokiDark) - st.sparkSuccess = "#00ff00" - st.sparkWarning = "#ffff00" - st.sparkDanger = "#ff0000" + st.sparkSuccess = lipgloss.Color("#00ff00") + st.sparkWarning = lipgloss.Color("#ffff00") + st.sparkDanger = lipgloss.Color("#ff0000") m := Model{st: st} - green := m.latencyStyle(50, "") - yellow := m.latencyStyle(300, "") - red := m.latencyStyle(800, "") + green := m.latencyStyle(50, nil) + yellow := m.latencyStyle(300, nil) + red := m.latencyStyle(800, nil) gfg := green.GetForeground() yfg := yellow.GetForeground() @@ -77,11 +79,11 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) { func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { st := newStyles(themeFlexokiDark) - st.sparkSuccess = "#00ff00" + st.sparkSuccess = lipgloss.Color("#00ff00") m := Model{st: st} - dim := m.latencyStyle(10, "") - bright := m.latencyStyle(190, "") + dim := m.latencyStyle(10, nil) + bright := m.latencyStyle(190, nil) if dim.GetForeground() == bright.GetForeground() { t.Error("10ms and 190ms should have different brightness within green band") @@ -91,7 +93,7 @@ func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { func TestLatencySparkline_OutputWidth(t *testing.T) { latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond} statuses := []bool{true, true, true} - got := styledModel.latencySparkline(latencies, statuses, 5, "") + got := styledModel.latencySparkline(latencies, statuses, 5, nil) count := utf8.RuneCountInString(stripANSI(got)) if count != 5 { t.Errorf("expected 5 rune-width output, got %d from %q", count, got) @@ -116,7 +118,7 @@ func stripANSI(s string) string { } func TestHeartbeatSparkline_Empty(t *testing.T) { - got := styledModel.heartbeatSparkline(nil, 10, "") + got := styledModel.heartbeatSparkline(nil, 10, nil) if !strings.Contains(got, "··········") { t.Errorf("empty heartbeat should be dots, got %q", got) } @@ -124,7 +126,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) { func TestHeartbeatSparkline_Mixed(t *testing.T) { statuses := []bool{true, false, true, true, false} - got := styledModel.heartbeatSparkline(statuses, 5, "") + got := styledModel.heartbeatSparkline(statuses, 5, nil) if len(got) == 0 { t.Error("heartbeat sparkline should not be empty") } @@ -132,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) { func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { statuses := []bool{true, true} - got := styledModel.heartbeatSparkline(statuses, 5, "") + got := styledModel.heartbeatSparkline(statuses, 5, nil) if !strings.Contains(got, "···") { t.Errorf("should have dot padding for width > data, got %q", got) } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 030f52d..b88c1a8 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -204,7 +204,7 @@ func (m Model) viewSitesTab() string { for i := start; i < end; i++ { site := m.sites[i] rowIdx := i - start - var rowBg lipgloss.Color + var rowBg lipgloss.TerminalColor if i == m.cursor { rowBg = m.theme.SelectedBg } else if rowIdx%2 == 1 { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index f81834f..bc37ed5 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -5,35 +5,43 @@ import ( "github.com/charmbracelet/lipgloss" ) +func cc(hex, ansi string) lipgloss.CompleteColor { + return lipgloss.CompleteColor{ + TrueColor: hex, + ANSI256: hex, + ANSI: ansi, + } +} + type Theme struct { Name string // Base layers - Bg lipgloss.Color - Surface lipgloss.Color - Panel lipgloss.Color - Border lipgloss.Color + Bg lipgloss.TerminalColor + Surface lipgloss.TerminalColor + Panel lipgloss.TerminalColor + Border lipgloss.TerminalColor // Text - Fg lipgloss.Color - Muted lipgloss.Color - Subtle lipgloss.Color + Fg lipgloss.TerminalColor + Muted lipgloss.TerminalColor + Subtle lipgloss.TerminalColor // Semantic - Success lipgloss.Color - Warning lipgloss.Color - Stale lipgloss.Color - Danger lipgloss.Color - Info lipgloss.Color - Accent lipgloss.Color - Purple lipgloss.Color + Success lipgloss.TerminalColor + Warning lipgloss.TerminalColor + Stale lipgloss.TerminalColor + Danger lipgloss.TerminalColor + Info lipgloss.TerminalColor + Accent lipgloss.TerminalColor + Purple lipgloss.TerminalColor // Table - ZebraBg lipgloss.Color + ZebraBg lipgloss.TerminalColor // Selection - SelectedFg lipgloss.Color - SelectedBg lipgloss.Color + SelectedFg lipgloss.TerminalColor + SelectedBg lipgloss.TerminalColor } var themes = []Theme{ @@ -46,107 +54,107 @@ var themes = []Theme{ var themeFlexokiDark = Theme{ Name: "Flexoki Dark", - Bg: "#1C1B1A", - Surface: "#282726", - Panel: "#343331", - Border: "#575653", - Fg: "#CECDC3", - Muted: "#878580", - Subtle: "#6F6E69", - Success: "#879A39", - Warning: "#D0A215", - Stale: "#DA702C", - Danger: "#D14D41", - Info: "#4385BE", - Accent: "#3AA99F", - Purple: "#8B7EC8", - ZebraBg: "#222120", - SelectedFg: "#FFFCF0", - SelectedBg: "#403E3C", + Bg: cc("#1C1B1A", ""), + Surface: cc("#282726", ""), + Panel: cc("#343331", ""), + Border: cc("#575653", "8"), + Fg: cc("#CECDC3", "15"), + Muted: cc("#878580", "7"), + Subtle: cc("#6F6E69", "8"), + Success: cc("#879A39", "10"), + Warning: cc("#D0A215", "11"), + Stale: cc("#DA702C", "3"), + Danger: cc("#D14D41", "9"), + Info: cc("#4385BE", "12"), + Accent: cc("#3AA99F", "14"), + Purple: cc("#8B7EC8", "13"), + ZebraBg: cc("#222120", ""), + SelectedFg: cc("#FFFCF0", "15"), + SelectedBg: cc("#403E3C", "4"), } var themeTokyoNight = Theme{ Name: "Tokyo Night", - Bg: "#1a1b26", - Surface: "#24283b", - Panel: "#292e42", - Border: "#3b4261", - Fg: "#c0caf5", - Muted: "#a9b1d6", - Subtle: "#565f89", - Success: "#9ece6a", - Warning: "#e0af68", - Stale: "#ff9e64", - Danger: "#f7768e", - Info: "#7aa2f7", - Accent: "#7dcfff", - Purple: "#bb9af7", - ZebraBg: "#1c1d28", - SelectedFg: "#c0caf5", - SelectedBg: "#292e42", + Bg: cc("#1a1b26", ""), + Surface: cc("#24283b", ""), + Panel: cc("#292e42", ""), + Border: cc("#3b4261", "8"), + Fg: cc("#c0caf5", "15"), + Muted: cc("#a9b1d6", "7"), + Subtle: cc("#565f89", "8"), + Success: cc("#9ece6a", "10"), + Warning: cc("#e0af68", "11"), + Stale: cc("#ff9e64", "3"), + Danger: cc("#f7768e", "9"), + Info: cc("#7aa2f7", "12"), + Accent: cc("#7dcfff", "14"), + Purple: cc("#bb9af7", "13"), + ZebraBg: cc("#1c1d28", ""), + SelectedFg: cc("#c0caf5", "15"), + SelectedBg: cc("#292e42", "4"), } var themeGruvbox = Theme{ Name: "Gruvbox", - Bg: "#282828", - Surface: "#3c3836", - Panel: "#504945", - Border: "#665c54", - Fg: "#ebdbb2", - Muted: "#bdae93", - Subtle: "#7c6f64", - Success: "#b8bb26", - Warning: "#fabd2f", - Stale: "#fe8019", - Danger: "#fb4934", - Info: "#83a598", - Accent: "#8ec07c", - Purple: "#d3869b", - ZebraBg: "#2a2a2a", - SelectedFg: "#fbf1c7", - SelectedBg: "#504945", + Bg: cc("#282828", ""), + Surface: cc("#3c3836", ""), + Panel: cc("#504945", ""), + Border: cc("#665c54", "8"), + Fg: cc("#ebdbb2", "15"), + Muted: cc("#bdae93", "7"), + Subtle: cc("#7c6f64", "8"), + Success: cc("#b8bb26", "10"), + Warning: cc("#fabd2f", "11"), + Stale: cc("#fe8019", "3"), + Danger: cc("#fb4934", "9"), + Info: cc("#83a598", "12"), + Accent: cc("#8ec07c", "14"), + Purple: cc("#d3869b", "13"), + ZebraBg: cc("#2a2a2a", ""), + SelectedFg: cc("#fbf1c7", "15"), + SelectedBg: cc("#504945", "4"), } var themeCatppuccinMocha = Theme{ Name: "Catppuccin Mocha", - Bg: "#1e1e2e", - Surface: "#313244", - Panel: "#45475a", - Border: "#585b70", - Fg: "#cdd6f4", - Muted: "#a6adc8", - Subtle: "#6c7086", - Success: "#a6e3a1", - Warning: "#f9e2af", - Stale: "#fab387", - Danger: "#f38ba8", - Info: "#89b4fa", - Accent: "#94e2d5", - Purple: "#cba6f7", - ZebraBg: "#232334", - SelectedFg: "#cdd6f4", - SelectedBg: "#45475a", + Bg: cc("#1e1e2e", ""), + Surface: cc("#313244", ""), + Panel: cc("#45475a", ""), + Border: cc("#585b70", "8"), + Fg: cc("#cdd6f4", "15"), + Muted: cc("#a6adc8", "7"), + Subtle: cc("#6c7086", "8"), + Success: cc("#a6e3a1", "10"), + Warning: cc("#f9e2af", "11"), + Stale: cc("#fab387", "3"), + Danger: cc("#f38ba8", "9"), + Info: cc("#89b4fa", "12"), + Accent: cc("#94e2d5", "14"), + Purple: cc("#cba6f7", "13"), + ZebraBg: cc("#232334", ""), + SelectedFg: cc("#cdd6f4", "15"), + SelectedBg: cc("#45475a", "4"), } var themeNord = Theme{ Name: "Nord", - Bg: "#2e3440", - Surface: "#3b4252", - Panel: "#434c5e", - Border: "#4c566a", - Fg: "#d8dee9", - Muted: "#d8dee9", - Subtle: "#4c566a", - Success: "#a3be8c", - Warning: "#ebcb8b", - Stale: "#d08770", - Danger: "#bf616a", - Info: "#81a1c1", - Accent: "#88c0d0", - Purple: "#b48ead", - ZebraBg: "#323845", - SelectedFg: "#eceff4", - SelectedBg: "#434c5e", + Bg: cc("#2e3440", ""), + Surface: cc("#3b4252", ""), + Panel: cc("#434c5e", ""), + Border: cc("#4c566a", "8"), + Fg: cc("#d8dee9", "15"), + Muted: cc("#d8dee9", "7"), + Subtle: cc("#4c566a", "8"), + Success: cc("#a3be8c", "10"), + Warning: cc("#ebcb8b", "11"), + Stale: cc("#d08770", "3"), + Danger: cc("#bf616a", "9"), + Info: cc("#81a1c1", "12"), + Accent: cc("#88c0d0", "14"), + Purple: cc("#b48ead", "13"), + ZebraBg: cc("#323845", ""), + SelectedFg: cc("#eceff4", "15"), + SelectedBg: cc("#434c5e", "4"), } func (t Theme) HuhTheme() *huh.Theme { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e7c49e0..91a2890 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -30,9 +30,9 @@ type styles struct { activeTab lipgloss.Style inactiveTab lipgloss.Style - sparkSuccess string - sparkWarning string - sparkDanger string + sparkSuccess lipgloss.TerminalColor + sparkWarning lipgloss.TerminalColor + sparkDanger lipgloss.TerminalColor tableHeaderStyle lipgloss.Style tableCellStyle lipgloss.Style @@ -55,9 +55,9 @@ func newStyles(t Theme) *styles { activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1), inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted), - sparkSuccess: string(t.Success), - sparkWarning: string(t.Warning), - sparkDanger: string(t.Danger), + sparkSuccess: t.Success, + sparkWarning: t.Warning, + sparkDanger: t.Danger, tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1), tableCellStyle: lipgloss.NewStyle().Padding(0, 1), diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index b9810bb..2be5b14 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -215,7 +215,7 @@ func (m Model) viewDetailPanel() string { b.WriteString(m.divider() + "\n") if site.Type == "push" { - b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, ""))) + b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil))) if len(hist.Statuses) > 0 { up := 0 for _, s := range hist.Statuses { @@ -228,7 +228,7 @@ func (m Model) viewDetailPanel() string { up, len(hist.Statuses)) } } else { - b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, ""))) + b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, nil))) var minL, maxL, total time.Duration count := 0 for i, l := range hist.Latencies {