feat: add /theme command with 22 built-in themes and file-based discovery

Add a theme registry that discovers themes from three sources (in
precedence order): built-in presets, user dir (~/.config/kit/themes/),
and project-local dir (.kit/themes/). Later sources override earlier
ones with the same name.

Ship 22 built-in presets ported from the OpenCode theme collection:
amoled, ayu, catppuccin, dracula, everforest, flexoki, github,
gruvbox, kanagawa, kitt, material, matrix, monokai, nord, one-dark,
rose-pine, solarized, synthwave, tokyonight, vercel, vesper, zenburn.

Add /theme slash command with tab-completion for listing available
themes and switching at runtime. Also fix .yml extension handling
in config.FilepathOr.
This commit is contained in:
Ed Zynda
2026-03-20 12:54:16 +03:00
parent 13060a20f9
commit a9970cf346
4 changed files with 677 additions and 4 deletions
+3 -4
View File
@@ -386,11 +386,10 @@ func FilepathOr[T any](key string, value *T) error {
fmt.Fprintf(os.Stderr, "%q", err)
os.Exit(1)
}
if filepath.Ext(absPath) == ".json" {
switch filepath.Ext(absPath) {
case ".json":
return json.Unmarshal(b, value)
}
if filepath.Ext(absPath) == ".yaml" {
case ".yaml", ".yml":
return yaml.Unmarshal(b, value)
}
}
+18
View File
@@ -94,6 +94,24 @@ var SlashCommands = []SlashCommand{
return matches
},
},
{
Name: "/theme",
Description: "Switch color theme (e.g. /theme catppuccin)",
Category: "System",
Complete: func(prefix string) []string {
names := ListThemes()
if prefix == "" {
return names
}
var matches []string
for _, n := range names {
if strings.HasPrefix(n, strings.ToLower(prefix)) {
matches = append(matches, n)
}
}
return matches
},
},
{
Name: "/quit",
Description: "Exit the application",
+50
View File
@@ -1059,6 +1059,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/theme":
if cmd := m.handleThemeCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
}
@@ -1857,6 +1863,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
m.printResetUsage()
case "/model":
return m.handleModelCommand("")
case "/theme":
return m.handleThemeCommand("")
case "/thinking":
return m.handleThinkingCommand("")
case "/compact":
@@ -2411,6 +2419,48 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
return nil
}
// --------------------------------------------------------------------------
// Theme command handler
// --------------------------------------------------------------------------
// handleThemeCommand switches the active color theme. With no arguments it
// lists available themes and highlights the active one. With a name argument
// (e.g. "/theme catppuccin") it switches immediately.
func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
if args == "" {
// List available themes.
names := ListThemes()
active := ActiveThemeName()
var lines []string
lines = append(lines, "Available themes:")
for _, name := range names {
if name == active {
lines = append(lines, fmt.Sprintf(" * %s (active)", name))
} else {
lines = append(lines, fmt.Sprintf(" %s", name))
}
}
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("User themes: %s", userThemesDir()))
if pdir := projectThemesDir(); pdir != "" {
lines = append(lines, fmt.Sprintf("Project themes: %s", pdir))
} else {
lines = append(lines, "Project themes: .kit/themes/ (not found)")
}
m.printSystemMessage(strings.Join(lines, "\n"))
return nil
}
if err := ApplyTheme(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Theme error: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Switched to theme: %s", args))
return nil
}
// --------------------------------------------------------------------------
// Thinking command handler
// --------------------------------------------------------------------------
+606
View File
@@ -0,0 +1,606 @@
package ui
import (
"encoding/json"
"fmt"
"image/color"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// 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 {
ac := func(pair [2]string) color.Color { return AdaptiveColor(pair[0], pair[1]) }
def := DefaultTheme()
acOr := func(pair [2]string, fb color.Color) color.Color {
if pair[0] == "" && pair[1] == "" {
return fb
}
return ac(pair)
}
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 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
// 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.
func ApplyTheme(name string) error {
t, err := LoadThemeByName(name)
if err != nil {
return err
}
SetTheme(t)
return nil
}
// RefreshThemeRegistry re-scans the themes directory. Call after the user
// drops a new file into ~/.config/kit/themes/.
func RefreshThemeRegistry() {
initThemeRegistry()
}
// 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()
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(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),
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),
},
}
}