diff --git a/internal/ui/messages.go b/internal/ui/messages.go index b57d2941..2c2e1b9b 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -111,14 +111,25 @@ func formatToolParams(toolArgs string, maxWidth int) string { result.WriteString(primaryVal) } - // Collect remaining parameters (skip large values like file content) + // Collect remaining parameters, skipping body-content keys (already + // rendered in the tool body) and any values that are too large. + bodyKeys := map[string]bool{ + "content": true, + "old_text": true, + "new_text": true, + "oldText": true, + "newText": true, + "todos": true, + } var remaining []string for key, val := range params { if key == primaryKey { continue } + if bodyKeys[key] { + continue + } valStr := fmt.Sprintf("%v", val) - // Skip very large values (e.g., oldString, newString, content, todos) if len(valStr) > 100 { continue } diff --git a/internal/ui/themes.go b/internal/ui/themes.go index 95693d76..79e17c89 100644 --- a/internal/ui/themes.go +++ b/internal/ui/themes.go @@ -7,11 +7,86 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "gopkg.in/yaml.v3" ) +// --------------------------------------------------------------------------- +// Color derivation helpers +// --------------------------------------------------------------------------- + +// parseHexColor parses a "#RRGGBB" hex string into r, g, b components (0-255). +func parseHexColor(hex string) (r, g, b int) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) == 6 { + if v, err := strconv.ParseUint(hex[0:2], 16, 8); err == nil { + r = int(v) + } + if v, err := strconv.ParseUint(hex[2:4], 16, 8); err == nil { + g = int(v) + } + if v, err := strconv.ParseUint(hex[4:6], 16, 8); err == nil { + b = int(v) + } + } + return +} + +// blendHex linearly interpolates between two hex colors by amount (0.0–1.0). +func blendHex(base, tint string, amount float64) string { + br, bg, bb := parseHexColor(base) + tr, tg, tb := parseHexColor(tint) + clamp := func(v int) int { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return v + } + r := clamp(int(float64(br)*(1-amount) + float64(tr)*amount)) + g := clamp(int(float64(bg)*(1-amount) + float64(tg)*amount)) + b := clamp(int(float64(bb)*(1-amount) + float64(tb)*amount)) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// deriveDiffBg computes diff / code background colors from the theme's +// background, success, and error hex pairs. Returns an adaptive color for each +// diff element. The tint amounts are tuned for subtle differentiation. +func deriveDiffBg(bgPair, successPair, errorPair [2]string) (diffInsert, diffDelete, diffEqual, diffMissing, codeBg, gutterBg, writeBg color.Color) { + derive := func(idx int) (color.Color, color.Color, color.Color, color.Color) { + bg := bgPair[idx] + // Contrast target: darken for light mode (idx 0), lighten for dark (idx 1). + contrast := "#000000" + if idx == 1 { + contrast = "#ffffff" + } + ins := blendHex(bg, successPair[idx], 0.13) + del := blendHex(bg, errorPair[idx], 0.13) + eq := blendHex(bg, contrast, 0.05) + miss := blendHex(bg, contrast, 0.03) + return AdaptiveColor(ins, ins), AdaptiveColor(del, del), AdaptiveColor(eq, eq), AdaptiveColor(miss, miss) + } + + // Pick the correct index based on detected background. + idx := 0 + if isDarkBg { + idx = 1 + } + insL, delL, eqL, missL := derive(idx) + diffInsert = insL + diffDelete = delL + diffEqual = eqL + diffMissing = missL + codeBg = eqL + gutterBg = missL + writeBg = insL + return +} + // ThemeEntry is a named, loadable theme — either built-in or discovered from disk. type ThemeEntry struct { Name string // Display name (filename stem or preset name) @@ -80,14 +155,9 @@ func makeTheme(p presetColors) Theme { Accent: acOr(p.accent, ac(p.primary)), Highlight: acOr(p.highlight, def.Highlight), } - // Derive diff/code backgrounds from the base background. - t.DiffInsertBg = def.DiffInsertBg - t.DiffDeleteBg = def.DiffDeleteBg - t.DiffEqualBg = def.DiffEqualBg - t.DiffMissingBg = def.DiffMissingBg - t.CodeBg = def.CodeBg - t.GutterBg = def.GutterBg - t.WriteBg = def.WriteBg + // Derive diff/code backgrounds from the theme's own palette. + t.DiffInsertBg, t.DiffDeleteBg, t.DiffEqualBg, t.DiffMissingBg, + t.CodeBg, t.GutterBg, t.WriteBg = deriveDiffBg(p.background, p.success, p.error_) // Markdown colors. t.Markdown = MarkdownThemeColors{ Text: t.Text, @@ -609,6 +679,17 @@ func loadThemeFile(path string) (Theme, error) { func fileConfigToTheme(cfg themeFileConfig) Theme { def := DefaultTheme() + + // Resolve the base background/success/error hex pairs for diff derivation. + // We need the raw hex strings to feed deriveDiffBg. + bgPair := resolveHexPair(cfg.Background, [2]string{"#F0F0F0", "#0D0D0D"}) + successPair := resolveHexPair(cfg.Success, [2]string{"#998800", "#CCAA00"}) + errorPair := resolveHexPair(cfg.Error, [2]string{"#CC0000", "#FF3333"}) + + // Derive diff backgrounds from the theme's own palette. + derivedInsert, derivedDelete, derivedEqual, derivedMissing, + derivedCodeBg, derivedGutterBg, derivedWriteBg := deriveDiffBg(bgPair, successPair, errorPair) + return Theme{ Primary: cfg.Primary.resolve(def.Primary), Secondary: cfg.Secondary.resolve(def.Secondary), @@ -627,13 +708,13 @@ func fileConfigToTheme(cfg themeFileConfig) Theme { Accent: cfg.Accent.resolve(def.Accent), Highlight: cfg.Highlight.resolve(def.Highlight), - DiffInsertBg: cfg.DiffInsertBg.resolve(def.DiffInsertBg), - DiffDeleteBg: cfg.DiffDeleteBg.resolve(def.DiffDeleteBg), - DiffEqualBg: cfg.DiffEqualBg.resolve(def.DiffEqualBg), - DiffMissingBg: cfg.DiffMissingBg.resolve(def.DiffMissingBg), - CodeBg: cfg.CodeBg.resolve(def.CodeBg), - GutterBg: cfg.GutterBg.resolve(def.GutterBg), - WriteBg: cfg.WriteBg.resolve(def.WriteBg), + DiffInsertBg: cfg.DiffInsertBg.resolve(derivedInsert), + DiffDeleteBg: cfg.DiffDeleteBg.resolve(derivedDelete), + DiffEqualBg: cfg.DiffEqualBg.resolve(derivedEqual), + DiffMissingBg: cfg.DiffMissingBg.resolve(derivedMissing), + CodeBg: cfg.CodeBg.resolve(derivedCodeBg), + GutterBg: cfg.GutterBg.resolve(derivedGutterBg), + WriteBg: cfg.WriteBg.resolve(derivedWriteBg), Markdown: MarkdownThemeColors{ Text: cfg.Markdown.Text.resolve(def.Markdown.Text), @@ -651,3 +732,17 @@ func fileConfigToTheme(cfg themeFileConfig) Theme { }, } } + +// resolveHexPair returns the hex pair from an adaptiveColorPair, falling back +// to defaults when the pair is empty. +func resolveHexPair(a adaptiveColorPair, fallback [2]string) [2]string { + light := a.Light + if light == "" { + light = fallback[0] + } + dark := a.Dark + if dark == "" { + dark = fallback[1] + } + return [2]string{light, dark} +} diff --git a/internal/ui/themes_test.go b/internal/ui/themes_test.go new file mode 100644 index 00000000..d27a7ebe --- /dev/null +++ b/internal/ui/themes_test.go @@ -0,0 +1,85 @@ +package ui + +import ( + "testing" +) + +func TestParseHexColor(t *testing.T) { + tests := []struct { + hex string + r, g, b int + }{ + {"#000000", 0, 0, 0}, + {"#ffffff", 255, 255, 255}, + {"#1e1e2e", 0x1e, 0x1e, 0x2e}, + {"#a6e3a1", 0xa6, 0xe3, 0xa1}, + {"#f38ba8", 0xf3, 0x8b, 0xa8}, + } + for _, tt := range tests { + r, g, b := parseHexColor(tt.hex) + if r != tt.r || g != tt.g || b != tt.b { + t.Errorf("parseHexColor(%q) = (%d,%d,%d), want (%d,%d,%d)", + tt.hex, r, g, b, tt.r, tt.g, tt.b) + } + } +} + +func TestBlendHex(t *testing.T) { + // Blending with 0 amount should return the base color. + got := blendHex("#1e1e2e", "#a6e3a1", 0.0) + if got != "#1e1e2e" { + t.Errorf("blendHex with 0.0 = %q, want #1e1e2e", got) + } + + // Blending with 1.0 amount should return the tint color. + got = blendHex("#1e1e2e", "#a6e3a1", 1.0) + if got != "#a6e3a1" { + t.Errorf("blendHex with 1.0 = %q, want #a6e3a1", got) + } + + // Blending black and white at 0.5 should give mid gray. + got = blendHex("#000000", "#ffffff", 0.5) + // 127 = int(0 + 255*0.5) — truncated, so #7f7f7f + if got != "#7f7f7f" { + t.Errorf("blendHex black/white at 0.5 = %q, want #7f7f7f", got) + } +} + +func TestDeriveDiffBgProducesDifferentColorsPerTheme(t *testing.T) { + // Catppuccin palette + catBg := [2]string{"#eff1f5", "#1e1e2e"} + catSuccess := [2]string{"#40a02b", "#a6e3a1"} + catError := [2]string{"#d20f39", "#f38ba8"} + + // KITT palette + kittBg := [2]string{"#F0F0F0", "#0D0D0D"} + kittSuccess := [2]string{"#998800", "#CCAA00"} + kittError := [2]string{"#CC0000", "#FF3333"} + + catInsert, catDelete, _, _, _, _, _ := deriveDiffBg(catBg, catSuccess, catError) + kittInsert, kittDelete, _, _, _, _, _ := deriveDiffBg(kittBg, kittSuccess, kittError) + + if catInsert == kittInsert { + t.Error("catppuccin DiffInsertBg should differ from kitt DiffInsertBg") + } + if catDelete == kittDelete { + t.Error("catppuccin DiffDeleteBg should differ from kitt DiffDeleteBg") + } +} + +func TestMakeThemeDerivesUniqueDiffColors(t *testing.T) { + themes := builtinThemes() + kitt := themes["kitt"] + cat := themes["catppuccin"] + + // The catppuccin diff backgrounds should NOT equal the kitt defaults. + if cat.DiffInsertBg == kitt.DiffInsertBg { + t.Error("catppuccin DiffInsertBg should differ from kitt default") + } + if cat.DiffDeleteBg == kitt.DiffDeleteBg { + t.Error("catppuccin DiffDeleteBg should differ from kitt default") + } + if cat.DiffEqualBg == kitt.DiffEqualBg { + t.Error("catppuccin DiffEqualBg should differ from kitt default") + } +}