From 95abb6fa6ebbe5dbca1115d3d395d9bb397084d0 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 20 Mar 2026 13:03:23 +0300 Subject: [PATCH] feat: add extension API for programmatic theme registration and switching Add RegisterTheme, SetTheme, and ListThemes to the extension Context, allowing extensions to create custom themes at runtime and switch between them. Uses ThemeColor/ThemeColorConfig concrete structs (no interfaces) for Yaegi safety. Include neon-theme.go example extension demonstrating the API. --- cmd/root.go | 22 ++++++++++ examples/extensions/neon-theme.go | 42 ++++++++++++++++++ internal/extensions/api.go | 71 +++++++++++++++++++++++++++++++ internal/extensions/symbols.go | 4 ++ internal/ui/themes.go | 31 ++++++++++++++ 5 files changed, 170 insertions(+) create mode 100644 examples/extensions/neon-theme.go diff --git a/cmd/root.go b/cmd/root.go index b758dfcc..9b0f670e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -936,6 +936,28 @@ func runNormalMode(ctx context.Context) error { SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) }, + RegisterTheme: func(name string, config extensions.ThemeColorConfig) { + tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} } + ui.RegisterThemeFromConfig(name, + tc(config.Primary), tc(config.Secondary), + tc(config.Success), tc(config.Warning), + tc(config.Error), tc(config.Info), + tc(config.Text), tc(config.Muted), + tc(config.VeryMuted), tc(config.Background), + tc(config.Border), tc(config.MutedBorder), + tc(config.System), tc(config.Tool), + tc(config.Accent), tc(config.Highlight), + tc(config.MdHeading), tc(config.MdLink), + tc(config.MdKeyword), tc(config.MdString), + tc(config.MdNumber), tc(config.MdComment), + ) + }, + SetTheme: func(name string) error { + return ui.ApplyTheme(name) + }, + ListThemes: func() []string { + return ui.ListThemes() + }, ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { ch := make(chan app.OverlayResponse, 1) appInstance.SendOverlayRequest(app.OverlayRequestEvent{ diff --git a/examples/extensions/neon-theme.go b/examples/extensions/neon-theme.go new file mode 100644 index 00000000..56eb556f --- /dev/null +++ b/examples/extensions/neon-theme.go @@ -0,0 +1,42 @@ +//go:build ignore + +package main + +import "kit/ext" + +// Init registers a "neon" theme and a /neon slash command to apply it. +// Demonstrates how extensions can create and set themes programmatically. +// +// Usage: kit -e examples/extensions/neon-theme.go +func Init(api ext.API) { + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + // Register a cyberpunk neon theme at startup. + ctx.RegisterTheme("neon", ext.ThemeColorConfig{ + Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"}, + Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"}, + Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"}, + Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"}, + Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"}, + Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"}, + Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"}, + Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"}, + MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"}, + MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"}, + MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"}, + }) + + ctx.PrintInfo("Neon theme registered! Use /theme neon to activate.") + }) + + // Also register a /neon slash command as a shortcut. + api.RegisterCommand(ext.CommandDef{ + Name: "neon", + Description: "Switch to the neon cyberpunk theme", + Execute: func(args string, ctx ext.Context) (string, error) { + if err := ctx.SetTheme("neon"); err != nil { + return "", err + } + return "Neon theme activated!", nil + }, + }) +} diff --git a/internal/extensions/api.go b/internal/extensions/api.go index bef0d52a..6e5009c4 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -485,6 +485,36 @@ type Context struct { // ctx.RenderMessage("build-status", "All 42 tests passed.") RenderMessage func(rendererName string, content string) + // RegisterTheme adds a named theme to the runtime theme registry. + // If a theme with the same name already exists it is replaced. + // The theme becomes available via /theme and ctx.SetTheme(). + // + // Example: + // + // ctx.RegisterTheme("neon", ext.ThemeColorConfig{ + // Primary: ext.ThemeColor{Dark: "#FF00FF"}, + // Secondary: ext.ThemeColor{Dark: "#00FFFF"}, + // Success: ext.ThemeColor{Dark: "#00FF00"}, + // Warning: ext.ThemeColor{Dark: "#FFFF00"}, + // Error: ext.ThemeColor{Dark: "#FF0000"}, + // Info: ext.ThemeColor{Dark: "#00FFFF"}, + // Text: ext.ThemeColor{Dark: "#FFFFFF"}, + // Background: ext.ThemeColor{Dark: "#000000"}, + // }) + RegisterTheme func(name string, config ThemeColorConfig) + + // SetTheme switches the active color theme by name. The name must + // match a built-in theme, a user/project theme file, or a theme + // registered via RegisterTheme. Returns an error if not found. + // + // Example: + // + // err := ctx.SetTheme("neon") + SetTheme func(name string) error + + // ListThemes returns the names of all available themes. + ListThemes func() []string + // ReloadExtensions hot-reloads all extensions from disk. Existing // extensions receive a SessionShutdown event, then new code is loaded // and receives a SessionStart event. Event handlers, commands, @@ -1723,3 +1753,44 @@ type BeforeCompactResult struct { } func (BeforeCompactResult) isResult() {} + +// --------------------------------------------------------------------------- +// Theme types (exposed to Yaegi — concrete structs, string hex colors) +// --------------------------------------------------------------------------- + +// ThemeColor is an adaptive color pair with light and dark hex values. +// Either field may be empty to inherit from the default theme. +type ThemeColor struct { + Light string + Dark string +} + +// ThemeColorConfig defines a complete color theme that extensions can register +// programmatically via ctx.RegisterTheme(). Uses plain hex strings (not +// color.Color) so the type is safe to pass across the Yaegi boundary. +type ThemeColorConfig struct { + Primary ThemeColor + Secondary ThemeColor + Success ThemeColor + Warning ThemeColor + Error ThemeColor + Info ThemeColor + Text ThemeColor + Muted ThemeColor + VeryMuted ThemeColor + Background ThemeColor + Border ThemeColor + MutedBorder ThemeColor + System ThemeColor + Tool ThemeColor + Accent ThemeColor + Highlight ThemeColor + + // Markdown/syntax highlighting overrides. + MdHeading ThemeColor + MdLink ThemeColor + MdKeyword ThemeColor + MdString ThemeColor + MdNumber ThemeColor + MdComment ThemeColor +} diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index d4677903..afb16c7b 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -119,6 +119,10 @@ func Symbols() interp.Exports { "SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)), "SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)), + // Theme types + "ThemeColor": reflect.ValueOf((*ThemeColor)(nil)), + "ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)), + // Event structs "ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)), "ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)), diff --git a/internal/ui/themes.go b/internal/ui/themes.go index 1478ac06..2206d2ad 100644 --- a/internal/ui/themes.go +++ b/internal/ui/themes.go @@ -454,6 +454,37 @@ func RefreshThemeRegistry() { initThemeRegistry() } +// 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 {