Files
Ed Zynda 78570d4188 remove dead code identified by audit
Removes ~600 lines of unreferenced code surfaced by deadcode + manual
audit (none of it reachable from production code paths or test setup):

- internal/models/pool.go: ProviderPool was never wired into kitsetup
  or the agent; the global pool singleton had zero callers.
- internal/ui/debug_logger.go: CLIDebugLogger was unreachable; debug
  routing goes through internal/tools/buffered_logger.go instead.
- internal/ui/tool_approval_input.go: tea.Model never instantiated;
  approvals are handled inline in model.go.
- internal/ui/cli.go: DisplayAssistantMessage / DisplayCancellation /
  GetDebugLogger had zero callers (the *WithModel variant is what
  event_handler.go uses).
- internal/ui/style/enhanced.go: Style{Card,Header,Subheader,Muted,
  Success,Error,Warning,Info} + Create{Separator,ProgressBar} — none
  used. CreateBadge stays (used by model.go).
- internal/ui/style/themes.go: RefreshThemeRegistry — never called.
- internal/ui/block_renderer.go: With{FullWidth,MarginTop,Padding{Left,
  Right},Background,Foreground,Width} — option helpers nobody calls.
- internal/ui/render/blocks.go: UserBlock, ToolBlock — replaced by
  inline rendering elsewhere; the test for UserBlock was rewritten to
  directly exercise HighlightFileTokens (which is what the test really
  cared about).
- internal/ui/commands/commands.go: GetAllCommandNames — no callers.
- internal/ui/message_items.go: NewTextMessageItem,
  NewSystemMessageItem + the entire SystemMessageItem type — model.go
  uses NewStyledMessageItem instead.
- internal/prompts/loader.go: Deduplicate — the loader does dedup
  internally; standalone helper was unused.
- internal/models/cache_options.go: mergeProviderOptions + its
  test-only consumer.
- internal/extensions/installer.go: Installer.GetInstalledPackages —
  intended for a 'kit ext list' command that was never built.
- internal/extensions/manifest.go: saveManifestToScope,
  saveManifestToPath, GetGlobalManifest, GetProjectManifest,
  addEntryToManifest, removeEntryFromManifest — package-level
  duplicates of *Installer methods. Tests rewritten to exercise the
  live Installer methods instead, which fixes a latent path-resolution
  inconsistency between manifestPathForScope and Installer.manifestPath
  (the former hard-coded paths, the latter respects projectGitRoot).
- internal/extensions/subagent.go: SpawnSubagent + helpers
  (generateSubagentID, findKitBinary, subagentJSONOutput). The
  subprocess-spawn implementation is unreachable; production code
  routes through kit.Kit.Subagent (in-process). Types
  (SubagentConfig/Result/Handle/etc.) and the SubagentHandle methods
  remain because they are exposed to extensions via Yaegi symbols and
  the Context.SpawnSubagent field.
- cmd/root.go: LoadConfigWithEnvSubstitution — one-line wrapper around
  kit.LoadConfigWithEnvSubstitution with zero callers.

go test -race ./... passes.
2026-05-07 12:20:08 +03:00

752 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package style
import (
"encoding/json"
"fmt"
"image/color"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"github.com/mark3labs/kit/internal/ui/prefs"
)
// ---------------------------------------------------------------------------
// 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.01.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)
Source string // "builtin" or absolute file path
theme Theme // Resolved theme (lazy-loaded for file-based)
loaded bool
}
// Theme returns the resolved ui.Theme, loading from disk on first access.
func (e *ThemeEntry) Theme() (Theme, error) {
if e.loaded {
return e.theme, nil
}
if e.Source == "builtin" {
// Already set at registration time.
return e.theme, nil
}
t, err := loadThemeFile(e.Source)
if err != nil {
return Theme{}, fmt.Errorf("loading theme %q: %w", e.Name, err)
}
e.theme = t
e.loaded = true
return e.theme, nil
}
// ---------------------------------------------------------------------------
// Built-in presets
// ---------------------------------------------------------------------------
// builtinThemes returns the set of themes shipped with Kit.
// makeTheme builds a full Theme from a compact palette spec. Fields left as
// zero color.Color inherit from the KITT default theme, keeping the preset
// definitions focused on what differs.
type presetColors struct {
primary, secondary, success, warning, error_, info [2]string // [light, dark]
text, muted, veryMuted, background, border, mutedBorder [2]string
system, tool, accent, highlight [2]string
mdKeyword, mdString, mdNumber, mdComment, mdHeading, mdLink [2]string
}
func makeTheme(p presetColors) Theme {
def := DefaultTheme()
ac := func(pair [2]string) color.Color {
c := AdaptiveColor(pair[0], pair[1])
if pair[0] == "" && pair[1] == "" {
return nil
}
return c
}
acOr := func(pair [2]string, fb color.Color) color.Color {
c := ac(pair)
if c == nil {
return fb
}
return c
}
t := Theme{
Primary: ac(p.primary),
Secondary: acOr(p.secondary, ac(p.primary)),
Success: ac(p.success),
Warning: ac(p.warning),
Error: ac(p.error_),
Info: ac(p.info),
Text: ac(p.text),
Muted: acOr(p.muted, def.Muted),
VeryMuted: acOr(p.veryMuted, def.VeryMuted),
Background: ac(p.background),
Border: acOr(p.border, def.Border),
MutedBorder: acOr(p.mutedBorder, def.MutedBorder),
System: acOr(p.system, ac(p.info)),
Tool: acOr(p.tool, ac(p.warning)),
Accent: acOr(p.accent, ac(p.primary)),
Highlight: acOr(p.highlight, def.Highlight),
}
// 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,
Muted: t.Muted,
Heading: acOr(p.mdHeading, t.Primary),
Emph: t.Warning,
Strong: t.Text,
Link: acOr(p.mdLink, t.Info),
Code: t.Muted,
Error: t.Error,
Keyword: acOr(p.mdKeyword, t.Primary),
String: acOr(p.mdString, t.Success),
Number: acOr(p.mdNumber, t.Warning),
Comment: acOr(p.mdComment, t.VeryMuted),
}
return t
}
// builtinThemes returns the set of themes shipped with Kit.
// Inspired by the OpenCode theme collection.
func builtinThemes() map[string]Theme {
return map[string]Theme{
"kitt": DefaultTheme(),
"catppuccin": makeTheme(presetColors{
primary: [2]string{"#8839ef", "#cba6f7"}, secondary: [2]string{"#04a5e5", "#89dceb"},
success: [2]string{"#40a02b", "#a6e3a1"}, warning: [2]string{"#df8e1d", "#f9e2af"},
error_: [2]string{"#d20f39", "#f38ba8"}, info: [2]string{"#1e66f5", "#89b4fa"},
text: [2]string{"#4c4f69", "#cdd6f4"}, muted: [2]string{"#6c6f85", "#a6adc8"},
veryMuted: [2]string{"#9ca0b0", "#6c7086"}, background: [2]string{"#eff1f5", "#1e1e2e"},
border: [2]string{"#acb0be", "#585b70"}, mutedBorder: [2]string{"#ccd0da", "#313244"},
system: [2]string{"#179299", "#94e2d5"}, tool: [2]string{"#fe640b", "#fab387"},
accent: [2]string{"#ea76cb", "#f5c2e7"}, highlight: [2]string{"#e6e9ef", "#181825"},
mdKeyword: [2]string{"#8839ef", "#cba6f7"}, mdString: [2]string{"#40a02b", "#a6e3a1"},
mdNumber: [2]string{"#fe640b", "#fab387"}, mdComment: [2]string{"#9ca0b0", "#6c7086"},
}),
"dracula": makeTheme(presetColors{
primary: [2]string{"#7c6bf5", "#bd93f9"}, secondary: [2]string{"#d16090", "#ff79c6"},
success: [2]string{"#2fbf71", "#50fa7b"}, warning: [2]string{"#f7a14d", "#ffb86c"},
error_: [2]string{"#d9536f", "#ff5555"}, info: [2]string{"#1d7fc5", "#8be9fd"},
text: [2]string{"#1f1f2f", "#f8f8f2"}, background: [2]string{"#f8f8f2", "#1d1e28"},
accent: [2]string{"#d16090", "#ff79c6"},
mdKeyword: [2]string{"#7c6bf5", "#bd93f9"}, mdString: [2]string{"#2fbf71", "#50fa7b"},
mdComment: [2]string{"#6272a4", "#6272a4"},
}),
"tokyonight": makeTheme(presetColors{
primary: [2]string{"#2e7de9", "#7aa2f7"}, secondary: [2]string{"#b15c00", "#ff9e64"},
success: [2]string{"#587539", "#9ece6a"}, warning: [2]string{"#8c6c3e", "#e0af68"},
error_: [2]string{"#c94060", "#f7768e"}, info: [2]string{"#007197", "#7dcfff"},
text: [2]string{"#273153", "#c0caf5"}, background: [2]string{"#e1e2e7", "#1a1b26"},
mdKeyword: [2]string{"#2e7de9", "#7aa2f7"}, mdString: [2]string{"#587539", "#9ece6a"},
mdComment: [2]string{"#848cb5", "#565f89"},
}),
"nord": makeTheme(presetColors{
primary: [2]string{"#5e81ac", "#88c0d0"}, secondary: [2]string{"#bf616a", "#d57780"},
success: [2]string{"#8fbcbb", "#a3be8c"}, warning: [2]string{"#d08770", "#d08770"},
error_: [2]string{"#bf616a", "#bf616a"}, info: [2]string{"#81a1c1", "#81a1c1"},
text: [2]string{"#2e3440", "#e5e9f0"}, background: [2]string{"#eceff4", "#2e3440"},
mdKeyword: [2]string{"#5e81ac", "#81a1c1"}, mdString: [2]string{"#8fbcbb", "#a3be8c"},
mdComment: [2]string{"#616e88", "#616e88"},
}),
"gruvbox": makeTheme(presetColors{
primary: [2]string{"#076678", "#83a598"}, secondary: [2]string{"#9d0006", "#fb4934"},
success: [2]string{"#79740e", "#b8bb26"}, warning: [2]string{"#b57614", "#fabd2f"},
error_: [2]string{"#9d0006", "#fb4934"}, info: [2]string{"#8f3f71", "#d3869b"},
text: [2]string{"#3c3836", "#ebdbb2"}, background: [2]string{"#fbf1c7", "#282828"},
mdKeyword: [2]string{"#9d0006", "#fb4934"}, mdString: [2]string{"#79740e", "#b8bb26"},
mdComment: [2]string{"#928374", "#928374"},
}),
"monokai": makeTheme(presetColors{
primary: [2]string{"#bf7bff", "#ae81ff"}, secondary: [2]string{"#d9487c", "#f92672"},
success: [2]string{"#4fb54b", "#a6e22e"}, warning: [2]string{"#f1a948", "#fd971f"},
error_: [2]string{"#e54b4b", "#f92672"}, info: [2]string{"#2d9ad7", "#66d9ef"},
text: [2]string{"#292318", "#f8f8f2"}, background: [2]string{"#fdf8ec", "#272822"},
mdKeyword: [2]string{"#d9487c", "#f92672"}, mdString: [2]string{"#4fb54b", "#a6e22e"},
mdComment: [2]string{"#888888", "#75715e"},
}),
"solarized": makeTheme(presetColors{
primary: [2]string{"#268bd2", "#6c71c4"}, secondary: [2]string{"#d33682", "#d33682"},
success: [2]string{"#859900", "#859900"}, warning: [2]string{"#b58900", "#b58900"},
error_: [2]string{"#dc322f", "#dc322f"}, info: [2]string{"#2aa198", "#2aa198"},
text: [2]string{"#586e75", "#93a1a1"}, background: [2]string{"#fdf6e3", "#002b36"},
mdKeyword: [2]string{"#268bd2", "#6c71c4"}, mdString: [2]string{"#859900", "#859900"},
mdComment: [2]string{"#93a1a1", "#586e75"},
}),
"github": makeTheme(presetColors{
primary: [2]string{"#0969da", "#58a6ff"}, secondary: [2]string{"#1b7c83", "#39c5cf"},
success: [2]string{"#1a7f37", "#3fb950"}, warning: [2]string{"#9a6700", "#e3b341"},
error_: [2]string{"#cf222e", "#f85149"}, info: [2]string{"#bc4c00", "#d29922"},
text: [2]string{"#24292f", "#c9d1d9"}, background: [2]string{"#ffffff", "#0d1117"},
mdKeyword: [2]string{"#0969da", "#58a6ff"}, mdString: [2]string{"#1a7f37", "#3fb950"},
mdComment: [2]string{"#6e7781", "#8b949e"},
}),
"one-dark": makeTheme(presetColors{
primary: [2]string{"#4078f2", "#61afef"}, secondary: [2]string{"#0184bc", "#56b6c2"},
success: [2]string{"#50a14f", "#98c379"}, warning: [2]string{"#c18401", "#e5c07b"},
error_: [2]string{"#e45649", "#e06c75"}, info: [2]string{"#986801", "#d19a66"},
text: [2]string{"#383a42", "#abb2bf"}, background: [2]string{"#fafafa", "#282c34"},
mdKeyword: [2]string{"#a626a4", "#c678dd"}, mdString: [2]string{"#50a14f", "#98c379"},
mdComment: [2]string{"#a0a1a7", "#5c6370"},
}),
"rose-pine": makeTheme(presetColors{
primary: [2]string{"#31748f", "#9ccfd8"}, secondary: [2]string{"#d7827e", "#ebbcba"},
success: [2]string{"#286983", "#31748f"}, warning: [2]string{"#ea9d34", "#f6c177"},
error_: [2]string{"#b4637a", "#eb6f92"}, info: [2]string{"#56949f", "#9ccfd8"},
text: [2]string{"#575279", "#e0def4"}, background: [2]string{"#faf4ed", "#191724"},
mdKeyword: [2]string{"#31748f", "#9ccfd8"}, mdString: [2]string{"#ea9d34", "#f6c177"},
mdComment: [2]string{"#9893a5", "#6e6a86"},
}),
"ayu": makeTheme(presetColors{
primary: [2]string{"#4aa8c8", "#3fb7e3"}, secondary: [2]string{"#ef7d71", "#f2856f"},
success: [2]string{"#5fb978", "#78d05c"}, warning: [2]string{"#ea9f41", "#e4a75c"},
error_: [2]string{"#e6656a", "#f58572"}, info: [2]string{"#2f9bce", "#66c6f1"},
text: [2]string{"#4f5964", "#d6dae0"}, background: [2]string{"#fdfaf4", "#0f1419"},
mdKeyword: [2]string{"#4aa8c8", "#3fb7e3"}, mdString: [2]string{"#5fb978", "#78d05c"},
mdComment: [2]string{"#abb0b6", "#5c6773"},
}),
"material": makeTheme(presetColors{
primary: [2]string{"#6182b8", "#82aaff"}, secondary: [2]string{"#39adb5", "#89ddff"},
success: [2]string{"#91b859", "#c3e88d"}, warning: [2]string{"#ffb300", "#ffcb6b"},
error_: [2]string{"#e53935", "#f07178"}, info: [2]string{"#f4511e", "#ffcb6b"},
text: [2]string{"#263238", "#eeffff"}, background: [2]string{"#fafafa", "#263238"},
mdKeyword: [2]string{"#6182b8", "#82aaff"}, mdString: [2]string{"#91b859", "#c3e88d"},
mdComment: [2]string{"#aabfc5", "#546e7a"},
}),
"everforest": makeTheme(presetColors{
primary: [2]string{"#8da101", "#a7c080"}, secondary: [2]string{"#df69ba", "#d699b6"},
success: [2]string{"#8da101", "#a7c080"}, warning: [2]string{"#f57d26", "#e69875"},
error_: [2]string{"#f85552", "#e67e80"}, info: [2]string{"#35a77c", "#83c092"},
text: [2]string{"#5c6a72", "#d3c6aa"}, background: [2]string{"#fdf6e3", "#2d353b"},
mdKeyword: [2]string{"#8da101", "#a7c080"}, mdString: [2]string{"#35a77c", "#83c092"},
mdComment: [2]string{"#939b84", "#859289"},
}),
"kanagawa": makeTheme(presetColors{
primary: [2]string{"#2D4F67", "#7E9CD8"}, secondary: [2]string{"#D27E99", "#D27E99"},
success: [2]string{"#98BB6C", "#98BB6C"}, warning: [2]string{"#D7A657", "#D7A657"},
error_: [2]string{"#E82424", "#E82424"}, info: [2]string{"#76946A", "#76946A"},
text: [2]string{"#54433A", "#DCD7BA"}, background: [2]string{"#F2E9DE", "#1F1F28"},
mdKeyword: [2]string{"#2D4F67", "#7E9CD8"}, mdString: [2]string{"#98BB6C", "#98BB6C"},
mdComment: [2]string{"#A09D98", "#727169"},
}),
"amoled": makeTheme(presetColors{
primary: [2]string{"#6200ff", "#b388ff"}, secondary: [2]string{"#ff0080", "#ff4081"},
success: [2]string{"#00e676", "#00ff88"}, warning: [2]string{"#ffab00", "#ffea00"},
error_: [2]string{"#ff1744", "#ff1744"}, info: [2]string{"#00b0ff", "#18ffff"},
text: [2]string{"#0a0a0a", "#ffffff"}, background: [2]string{"#f0f0f0", "#000000"},
mdKeyword: [2]string{"#6200ff", "#b388ff"}, mdString: [2]string{"#00e676", "#00ff88"},
mdComment: [2]string{"#757575", "#424242"},
}),
"synthwave": makeTheme(presetColors{
primary: [2]string{"#00bcd4", "#36f9f6"}, secondary: [2]string{"#9c27b0", "#b084eb"},
success: [2]string{"#4caf50", "#72f1b8"}, warning: [2]string{"#ff9800", "#fede5d"},
error_: [2]string{"#f44336", "#fe4450"}, info: [2]string{"#ff5722", "#ff8b39"},
text: [2]string{"#262335", "#ffffff"}, background: [2]string{"#fafafa", "#262335"},
mdKeyword: [2]string{"#9c27b0", "#b084eb"}, mdString: [2]string{"#4caf50", "#72f1b8"},
mdComment: [2]string{"#848bbd", "#848bbd"},
}),
"vesper": makeTheme(presetColors{
primary: [2]string{"#FFC799", "#FFC799"}, secondary: [2]string{"#B30000", "#FF8080"},
success: [2]string{"#99FFE4", "#99FFE4"}, warning: [2]string{"#FFC799", "#FFC799"},
error_: [2]string{"#FF8080", "#FF8080"}, info: [2]string{"#FFC799", "#FFC799"},
text: [2]string{"#1a1a1a", "#FFF"}, background: [2]string{"#F0F0F0", "#101010"},
mdKeyword: [2]string{"#FFC799", "#FFC799"}, mdString: [2]string{"#99FFE4", "#99FFE4"},
mdComment: [2]string{"#7a7a7a", "#505050"},
}),
"flexoki": makeTheme(presetColors{
primary: [2]string{"#205EA6", "#DA702C"}, secondary: [2]string{"#BC5215", "#8B7EC8"},
success: [2]string{"#66800B", "#879A39"}, warning: [2]string{"#BC5215", "#DA702C"},
error_: [2]string{"#AF3029", "#D14D41"}, info: [2]string{"#24837B", "#3AA99F"},
text: [2]string{"#100F0F", "#CECDC3"}, background: [2]string{"#FFFCF0", "#100F0F"},
mdKeyword: [2]string{"#205EA6", "#DA702C"}, mdString: [2]string{"#66800B", "#879A39"},
mdComment: [2]string{"#878580", "#878580"},
}),
"matrix": makeTheme(presetColors{
primary: [2]string{"#1cc24b", "#2eff6a"}, secondary: [2]string{"#c770ff", "#c770ff"},
success: [2]string{"#1cc24b", "#62ff94"}, warning: [2]string{"#e6ff57", "#e6ff57"},
error_: [2]string{"#ff4b4b", "#ff4b4b"}, info: [2]string{"#30b3ff", "#30b3ff"},
text: [2]string{"#203022", "#62ff94"}, background: [2]string{"#eef3ea", "#0a0e0a"},
mdKeyword: [2]string{"#1cc24b", "#2eff6a"}, mdString: [2]string{"#1cc24b", "#62ff94"},
mdComment: [2]string{"#5a7a5e", "#3a5a3e"},
}),
"vercel": makeTheme(presetColors{
primary: [2]string{"#0070F3", "#0070F3"}, secondary: [2]string{"#8E4EC6", "#8E4EC6"},
success: [2]string{"#388E3C", "#46A758"}, warning: [2]string{"#FF9500", "#FFB224"},
error_: [2]string{"#DC3545", "#E5484D"}, info: [2]string{"#0070F3", "#52A8FF"},
text: [2]string{"#171717", "#EDEDED"}, background: [2]string{"#FFFFFF", "#000000"},
mdKeyword: [2]string{"#0070F3", "#0070F3"}, mdString: [2]string{"#388E3C", "#46A758"},
mdComment: [2]string{"#6B6B6B", "#666666"},
}),
"zenburn": makeTheme(presetColors{
primary: [2]string{"#5f7f8f", "#8cd0d3"}, secondary: [2]string{"#5f8f8f", "#93e0e3"},
success: [2]string{"#5f8f5f", "#7f9f7f"}, warning: [2]string{"#8f8f5f", "#f0dfaf"},
error_: [2]string{"#8f5f5f", "#cc9393"}, info: [2]string{"#8f7f5f", "#dfaf8f"},
text: [2]string{"#3f3f3f", "#dcdccc"}, background: [2]string{"#ffffef", "#3f3f3f"},
mdKeyword: [2]string{"#5f7f8f", "#8cd0d3"}, mdString: [2]string{"#5f8f5f", "#cc9393"},
mdComment: [2]string{"#7f7f7f", "#7f9f7f"},
}),
}
}
// ---------------------------------------------------------------------------
// Theme registry (global)
// ---------------------------------------------------------------------------
var themeRegistry []ThemeEntry
// initThemeRegistry populates the registry from built-ins, user themes, and
// project-local themes. Later sources override earlier ones with the same name:
// 1. Built-in presets
// 2. User themes (~/.config/kit/themes/)
// 3. Project-local (.kit/themes/ in the working directory)
func initThemeRegistry() {
themeRegistry = nil
// 1. Built-in presets.
for name, t := range builtinThemes() {
themeRegistry = append(themeRegistry, ThemeEntry{
Name: name,
Source: "builtin",
theme: t,
loaded: true,
})
}
// 2. User themes from ~/.config/kit/themes/
scanThemesDir(UserThemesDir())
// 3. Project-local themes from .kit/themes/
scanThemesDir(ProjectThemesDir())
sortRegistry()
}
// scanThemesDir adds all .yml/.yaml/.json theme files from dir to the registry.
// Files override any existing entry with the same stem name.
func scanThemesDir(dir string) {
if dir == "" {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext != ".yml" && ext != ".yaml" && ext != ".json" {
continue
}
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
removeFromRegistry(name)
themeRegistry = append(themeRegistry, ThemeEntry{
Name: name,
Source: filepath.Join(dir, entry.Name()),
})
}
}
func sortRegistry() {
sort.Slice(themeRegistry, func(i, j int) bool {
return themeRegistry[i].Name < themeRegistry[j].Name
})
}
func removeFromRegistry(name string) {
for i := range themeRegistry {
if themeRegistry[i].Name == name {
themeRegistry = append(themeRegistry[:i], themeRegistry[i+1:]...)
return
}
}
}
// userThemesDir returns ~/.config/kit/themes, creating it if needed.
func UserThemesDir() string {
cfgDir, err := os.UserConfigDir()
if err != nil {
return ""
}
dir := filepath.Join(cfgDir, "kit", "themes")
_ = os.MkdirAll(dir, 0o755)
return dir
}
// projectThemesDir returns .kit/themes/ relative to the working directory.
// Returns "" if the directory doesn't exist (does NOT create it).
func ProjectThemesDir() string {
dir := filepath.Join(".kit", "themes")
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
return dir
}
return abs
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
// ListThemes returns the names of all available themes (built-in + user).
func ListThemes() []string {
if themeRegistry == nil {
initThemeRegistry()
}
names := make([]string, len(themeRegistry))
for i := range themeRegistry {
names[i] = themeRegistry[i].Name
}
return names
}
// LoadThemeByName looks up a theme by name, loads it if needed, and returns it.
func LoadThemeByName(name string) (Theme, error) {
if themeRegistry == nil {
initThemeRegistry()
}
for i := range themeRegistry {
if themeRegistry[i].Name == name {
return themeRegistry[i].Theme()
}
}
return Theme{}, fmt.Errorf("theme %q not found", name)
}
// ApplyTheme loads a theme by name and sets it as the active global theme.
// The selection is persisted to ~/.config/kit/preferences.yml so it survives
// across sessions. Persistence errors are silently ignored — the theme is
// still applied in-memory even if the write fails.
func ApplyTheme(name string) error {
t, err := LoadThemeByName(name)
if err != nil {
return err
}
SetTheme(t)
_ = prefs.SaveThemePreference(name)
return nil
}
// ApplyThemeWithoutSave loads a theme by name and sets it as the active global
// theme without persisting the choice. Used at startup to restore a previously
// saved preference without redundantly re-writing it.
func ApplyThemeWithoutSave(name string) error {
t, err := LoadThemeByName(name)
if err != nil {
return err
}
SetTheme(t)
return nil
}
// RegisterThemeFromConfig adds a theme to the runtime registry from an
// extension's ThemeColorConfig (string hex pairs). Replaces any existing
// entry with the same name. The theme is immediately available via
// ListThemes, LoadThemeByName, and ApplyTheme.
func RegisterThemeFromConfig(name string, primary, secondary, success, warning, error_, info, text, muted, veryMuted, background, border, mutedBorder, system, tool, accent, highlight, mdHeading, mdLink, mdKeyword, mdString, mdNumber, mdComment [2]string) {
if themeRegistry == nil {
initThemeRegistry()
}
t := makeTheme(presetColors{
primary: primary, secondary: secondary,
success: success, warning: warning,
error_: error_, info: info,
text: text, muted: muted,
veryMuted: veryMuted, background: background,
border: border, mutedBorder: mutedBorder,
system: system, tool: tool,
accent: accent, highlight: highlight,
mdHeading: mdHeading, mdLink: mdLink,
mdKeyword: mdKeyword, mdString: mdString,
mdNumber: mdNumber, mdComment: mdComment,
})
removeFromRegistry(name)
themeRegistry = append(themeRegistry, ThemeEntry{
Name: name,
Source: "extension",
theme: t,
loaded: true,
})
sortRegistry()
}
// ActiveThemeName returns the name of the currently active theme by comparing
// against known entries. Returns "custom" if no match is found.
func ActiveThemeName() string {
if themeRegistry == nil {
initThemeRegistry()
}
current := GetTheme()
for _, e := range themeRegistry {
if !e.loaded {
continue
}
if e.theme.Primary == current.Primary &&
e.theme.Secondary == current.Secondary &&
e.theme.Error == current.Error &&
e.theme.Text == current.Text {
return e.Name
}
}
return "custom"
}
// ---------------------------------------------------------------------------
// File loading
// ---------------------------------------------------------------------------
// themeFileConfig mirrors config.Theme for unmarshaling theme files.
// Uses the same adaptive color structure.
type themeFileConfig struct {
Primary adaptiveColorPair `json:"primary,omitzero" yaml:"primary,omitempty"`
Secondary adaptiveColorPair `json:"secondary,omitzero" yaml:"secondary,omitempty"`
Success adaptiveColorPair `json:"success,omitzero" yaml:"success,omitempty"`
Warning adaptiveColorPair `json:"warning,omitzero" yaml:"warning,omitempty"`
Error adaptiveColorPair `json:"error,omitzero" yaml:"error,omitempty"`
Info adaptiveColorPair `json:"info,omitzero" yaml:"info,omitempty"`
Text adaptiveColorPair `json:"text,omitzero" yaml:"text,omitempty"`
Muted adaptiveColorPair `json:"muted,omitzero" yaml:"muted,omitempty"`
VeryMuted adaptiveColorPair `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
Background adaptiveColorPair `json:"background,omitzero" yaml:"background,omitempty"`
Border adaptiveColorPair `json:"border,omitzero" yaml:"border,omitempty"`
MutedBorder adaptiveColorPair `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
System adaptiveColorPair `json:"system,omitzero" yaml:"system,omitempty"`
Tool adaptiveColorPair `json:"tool,omitzero" yaml:"tool,omitempty"`
Accent adaptiveColorPair `json:"accent,omitzero" yaml:"accent,omitempty"`
Highlight adaptiveColorPair `json:"highlight,omitzero" yaml:"highlight,omitempty"`
DiffInsertBg adaptiveColorPair `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
DiffDeleteBg adaptiveColorPair `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
DiffEqualBg adaptiveColorPair `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
DiffMissingBg adaptiveColorPair `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
CodeBg adaptiveColorPair `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
GutterBg adaptiveColorPair `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
WriteBg adaptiveColorPair `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
Markdown struct {
Text adaptiveColorPair `json:"text,omitzero" yaml:"text,omitempty"`
Muted adaptiveColorPair `json:"muted,omitzero" yaml:"muted,omitempty"`
Heading adaptiveColorPair `json:"heading,omitzero" yaml:"heading,omitempty"`
Emph adaptiveColorPair `json:"emph,omitzero" yaml:"emph,omitempty"`
Strong adaptiveColorPair `json:"strong,omitzero" yaml:"strong,omitempty"`
Link adaptiveColorPair `json:"link,omitzero" yaml:"link,omitempty"`
Code adaptiveColorPair `json:"code,omitzero" yaml:"code,omitempty"`
Error adaptiveColorPair `json:"error,omitzero" yaml:"error,omitempty"`
Keyword adaptiveColorPair `json:"keyword,omitzero" yaml:"keyword,omitempty"`
String adaptiveColorPair `json:"string,omitzero" yaml:"string,omitempty"`
Number adaptiveColorPair `json:"number,omitzero" yaml:"number,omitempty"`
Comment adaptiveColorPair `json:"comment,omitzero" yaml:"comment,omitempty"`
} `json:"markdown,omitzero" yaml:"markdown,omitempty"`
}
type adaptiveColorPair struct {
Light string `json:"light,omitempty" yaml:"light,omitempty"`
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
}
// resolve converts an adaptiveColorPair to a resolved color.Color,
// falling back to fallback when both Light and Dark are empty.
func (a adaptiveColorPair) resolve(fallback color.Color) color.Color {
if a.Light == "" && a.Dark == "" {
return fallback
}
return AdaptiveColor(a.Light, a.Dark)
}
func loadThemeFile(path string) (Theme, error) {
data, err := os.ReadFile(path)
if err != nil {
return Theme{}, err
}
var cfg themeFileConfig
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".json":
err = json.Unmarshal(data, &cfg)
case ".yaml", ".yml":
err = yaml.Unmarshal(data, &cfg)
default:
return Theme{}, fmt.Errorf("unsupported theme file format: %s", ext)
}
if err != nil {
return Theme{}, err
}
return fileConfigToTheme(cfg), nil
}
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),
Success: cfg.Success.resolve(def.Success),
Warning: cfg.Warning.resolve(def.Warning),
Error: cfg.Error.resolve(def.Error),
Info: cfg.Info.resolve(def.Info),
Text: cfg.Text.resolve(def.Text),
Muted: cfg.Muted.resolve(def.Muted),
VeryMuted: cfg.VeryMuted.resolve(def.VeryMuted),
Background: cfg.Background.resolve(def.Background),
Border: cfg.Border.resolve(def.Border),
MutedBorder: cfg.MutedBorder.resolve(def.MutedBorder),
System: cfg.System.resolve(def.System),
Tool: cfg.Tool.resolve(def.Tool),
Accent: cfg.Accent.resolve(def.Accent),
Highlight: cfg.Highlight.resolve(def.Highlight),
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),
Muted: cfg.Markdown.Muted.resolve(def.Markdown.Muted),
Heading: cfg.Markdown.Heading.resolve(def.Markdown.Heading),
Emph: cfg.Markdown.Emph.resolve(def.Markdown.Emph),
Strong: cfg.Markdown.Strong.resolve(def.Markdown.Strong),
Link: cfg.Markdown.Link.resolve(def.Markdown.Link),
Code: cfg.Markdown.Code.resolve(def.Markdown.Code),
Error: cfg.Markdown.Error.resolve(def.Markdown.Error),
Keyword: cfg.Markdown.Keyword.resolve(def.Markdown.Keyword),
String: cfg.Markdown.String.resolve(def.Markdown.String),
Number: cfg.Markdown.Number.resolve(def.Markdown.Number),
Comment: cfg.Markdown.Comment.resolve(def.Markdown.Comment),
},
}
}
// 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}
}