diff --git a/README.md b/README.md index e987c1a..5e04dff 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ Kernel includes a suite of instant commands accessible directly from the input b | `/model ` | Dynamically switch models (e.g. `/model gemini-3.5-flash`, `/model gpt-4o`) | | `/web ` | Performs a zero-key live web search and returns markdown summaries | | `/read ` | Reads and displays syntax-highlighted code from any project file | +| `/theme [name]` | Dynamically switch color themes (`cyberpunk`, `matrix`, `dracula`, `nord`, `tokyo`, `synthwave`) | +| `/commit [-y]` | AI Git Conventional Commit message generator & 1-click committer | | `/save [filename]` | Exports the entire chat session to a formatted `.md` file | | `$ ` or `/exec ` | Executes a local shell command and embeds stdout/stderr in chat | | `:q`, `/quit`, `exit` | Saves conversation to history and cleanly exits | diff --git a/internal/audio/assets/cat_meow.wav b/internal/audio/assets/cat_meow.wav new file mode 100644 index 0000000..f3d8fa3 Binary files /dev/null and b/internal/audio/assets/cat_meow.wav differ diff --git a/internal/audio/assets/dog_bark.wav b/internal/audio/assets/dog_bark.wav new file mode 100644 index 0000000..d3d26be Binary files /dev/null and b/internal/audio/assets/dog_bark.wav differ diff --git a/internal/audio/audio.go b/internal/audio/audio.go index f02aeaf..407bc5f 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -2,6 +2,7 @@ package audio import ( "context" + _ "embed" "fmt" "os/exec" "runtime" @@ -9,11 +10,17 @@ import ( "time" ) +//go:embed assets/dog_bark.wav +var dogBarkWAV []byte + +//go:embed assets/cat_meow.wav +var catMeowWAV []byte + var ( VoiceEnabled = true ) -// PlayCompanionSound plays an animal-specific vocalization or sci-fi sound effect +// PlayCompanionSound plays an authentic animal vocalization or sci-fi sound effect func PlayCompanionSound(skin string) { if !VoiceEnabled { return @@ -21,51 +28,37 @@ func PlayCompanionSound(skin string) { skin = strings.ToLower(skin) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) - defer cancel() - - if runtime.GOOS == "windows" { - var script string - switch skin { - case "cat": - // Cat cute meow vocalization - script = `(New-Object -ComObject SAPI.SpVoice).Speak('Meow!')` - case "dragon": - // Dragon roar - script = `[console]::beep(200, 150); (New-Object -ComObject SAPI.SpVoice).Speak('Rawr!')` - case "drone": - // Futuristic digital sonar chirp sequence - script = `[console]::beep(1800, 40); [console]::beep(2400, 60); [console]::beep(3200, 80)` - default: // "dog" - // Dog enthusiastic Woof vocalization - script = `(New-Object -ComObject SAPI.SpVoice).Speak('Woof! Woof!')` - } - cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", script) - _ = cmd.Run() - } else if runtime.GOOS == "darwin" { - switch skin { - case "cat": - cmd := exec.CommandContext(ctx, "say", "-v", "Victoria", "Meow") - _ = cmd.Run() - case "dragon": - cmd := exec.CommandContext(ctx, "say", "-v", "Zarvox", "Rawr") - _ = cmd.Run() - case "drone": - cmd := exec.CommandContext(ctx, "afplay", "/System/Library/Sounds/Submarine.aiff") + switch skin { + case "dog": + playWAV(dogBarkWAV) + case "cat": + playWAV(catMeowWAV) + case "drone": + go func() { + if runtime.GOOS == "windows" { + script := `[console]::beep(1800, 40); [console]::beep(2400, 60); [console]::beep(3200, 80)` + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) _ = cmd.Run() - default: - cmd := exec.CommandContext(ctx, "say", "-v", "Fred", "Woof woof") + } else { + fmt.Print("\a") + } + }() + case "dragon": + go func() { + if runtime.GOOS == "windows" { + script := `[console]::beep(220, 100); [console]::beep(180, 150); [console]::beep(140, 200)` + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) _ = cmd.Run() + } else { + fmt.Print("\a") } - } else { - // Linux bell fallback - fmt.Print("\a") - } - }() + }() + default: + playWAV(dogBarkWAV) + } } -// Bark is a backwards-compatible alias for the dog vocalization +// Bark is a backwards-compatible alias for the dog bark func Bark() { PlayCompanionSound("dog") } diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go new file mode 100644 index 0000000..77ecdfb --- /dev/null +++ b/internal/audio/audio_test.go @@ -0,0 +1,23 @@ +package audio + +import ( + "testing" + "time" +) + +func TestPlayCompanionSound(t *testing.T) { + if len(dogBarkWAV) == 0 { + t.Fatal("expected embedded dogBarkWAV to be non-empty") + } + if len(catMeowWAV) == 0 { + t.Fatal("expected embedded catMeowWAV to be non-empty") + } + + // Test calls without panic + PlayCompanionSound("dog") + PlayCompanionSound("cat") + PlayCompanionSound("dragon") + PlayCompanionSound("drone") + Bark() + time.Sleep(100 * time.Millisecond) +} diff --git a/internal/audio/play_other.go b/internal/audio/play_other.go new file mode 100644 index 0000000..8c645b9 --- /dev/null +++ b/internal/audio/play_other.go @@ -0,0 +1,28 @@ +//go:build !windows + +package audio + +import ( + "os" + "os/exec" + "runtime" +) + +func playWAV(data []byte) { + if len(data) == 0 { + return + } + tmp, err := os.CreateTemp("", "kernel_sound_*.wav") + if err != nil { + return + } + defer os.Remove(tmp.Name()) + _, _ = tmp.Write(data) + _ = tmp.Close() + + if runtime.GOOS == "darwin" { + _ = exec.Command("afplay", tmp.Name()).Run() + } else { + _ = exec.Command("aplay", tmp.Name()).Run() + } +} diff --git a/internal/audio/play_windows.go b/internal/audio/play_windows.go new file mode 100644 index 0000000..fd8733d --- /dev/null +++ b/internal/audio/play_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package audio + +import ( + "syscall" + "unsafe" +) + +var ( + winmm = syscall.NewLazyDLL("winmm.dll") + procPlaySoundW = winmm.NewProc("PlaySoundW") +) + +const ( + SND_ASYNC = 0x0001 + SND_MEMORY = 0x0004 +) + +func playWAV(data []byte) { + if len(data) == 0 { + return + } + procPlaySoundW.Call( + uintptr(unsafe.Pointer(&data[0])), + 0, + uintptr(SND_MEMORY|SND_ASYNC), + ) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6c7c966..e79598d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { CustomEndpoint string `json:"custom_endpoint,omitempty"` CustomKey string `json:"custom_key,omitempty"` CustomModel string `json:"custom_model,omitempty"` + Theme string `json:"theme,omitempty"` } func GetConfigDir() (string, error) { @@ -82,6 +83,10 @@ func LoadConfig() (*Config, error) { cfg.CustomEndpoint = envEndpoint } + if cfg.Theme == "" { + cfg.Theme = "cyberpunk" + } + return &cfg, nil } diff --git a/internal/tools/git.go b/internal/tools/git.go new file mode 100644 index 0000000..3f4d2da --- /dev/null +++ b/internal/tools/git.go @@ -0,0 +1,112 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/404kernel/kernel/internal/exec" +) + +type GitChanges struct { + HasChanges bool + Status string + Diff string + Files []string +} + +// InspectGitChanges queries git status and git diff in the current workspace. +func InspectGitChanges() GitChanges { + ctx := context.Background() + + statusRes := exec.RunShellCommand(ctx, "git status --porcelain", 5*time.Second) + statusText := strings.TrimSpace(statusRes.Stdout) + if statusText == "" { + return GitChanges{HasChanges: false} + } + + var files []string + for _, line := range strings.Split(statusText, "\n") { + trimmed := strings.TrimSpace(line) + if len(trimmed) > 3 { + files = append(files, strings.TrimSpace(trimmed[2:])) + } + } + + // First try staged diff + diffRes := exec.RunShellCommand(ctx, "git diff --cached", 5*time.Second) + diffText := strings.TrimSpace(diffRes.Stdout) + + // If no staged diff, get unstaged working diff + if diffText == "" { + diffRes = exec.RunShellCommand(ctx, "git diff", 5*time.Second) + diffText = strings.TrimSpace(diffRes.Stdout) + } + + // Truncate diff if massive to prevent token explosion + if len(diffText) > 6000 { + diffText = diffText[:6000] + "\n... [diff truncated for length] ..." + } + + return GitChanges{ + HasChanges: true, + Status: statusText, + Diff: diffText, + Files: files, + } +} + +// BuildCommitPrompt crafts an instruction prompt for the LLM to output a conventional commit. +func BuildCommitPrompt(changes GitChanges) string { + return fmt.Sprintf(`You are an expert Git and software release assistant. +Based on the following git status and git diff, write a concise, professional Conventional Commit message. + +Rules: +1. First line must be formatted as: type(scope): short imperative summary (max 72 chars). + Types: feat, fix, docs, style, refactor, perf, test, chore. +2. If helpful, include 2-3 short bullet points after an empty line describing key changes. +3. Output ONLY the commit message. No explanations, no markdown code fences, no surrounding quotes. + +Git Status: +%s + +Git Diff: +%s +`, changes.Status, changes.Diff) +} + +// HeuristicCommitMessage generates an intelligent fallback commit message offline or in demo mode. +func HeuristicCommitMessage(changes GitChanges) string { + if len(changes.Files) == 0 { + return "chore: update workspace files" + } + + scope := "core" + firstFile := changes.Files[0] + if strings.Contains(firstFile, "ui/") || strings.Contains(firstFile, "styles") { + scope = "ui" + } else if strings.Contains(firstFile, "tools/") { + scope = "tools" + } else if strings.Contains(firstFile, "config/") { + scope = "config" + } else if strings.HasSuffix(firstFile, ".md") { + return "docs: update documentation and usage guides" + } + + // Detect if features were added + hasAdditions := strings.Contains(changes.Diff, "+") + if hasAdditions { + return fmt.Sprintf("feat(%s): implement new functionality and enhancements across %d files", scope, len(changes.Files)) + } + + return fmt.Sprintf("refactor(%s): update and refine %s", scope, firstFile) +} + +// RunGitCommit executes git add and git commit with the given message. +func RunGitCommit(message string) exec.ExecResult { + // Escape double quotes for shell execution + escaped := strings.ReplaceAll(message, `"`, `\"`) + cmd := fmt.Sprintf(`git add -A && git commit -m "%s"`, escaped) + return exec.RunShellCommand(context.Background(), cmd, 10*time.Second) +} diff --git a/internal/tools/git_test.go b/internal/tools/git_test.go new file mode 100644 index 0000000..80d30cc --- /dev/null +++ b/internal/tools/git_test.go @@ -0,0 +1,41 @@ +package tools + +import ( + "strings" + "testing" +) + +func TestHeuristicCommitMessage(t *testing.T) { + changes := GitChanges{ + HasChanges: true, + Files: []string{"internal/ui/styles.go", "internal/ui/model.go"}, + Diff: "+func ApplyTheme()", + } + + msg := HeuristicCommitMessage(changes) + if !strings.HasPrefix(msg, "feat(ui):") { + t.Errorf("expected feat(ui): prefix, got: %s", msg) + } + + docChanges := GitChanges{ + HasChanges: true, + Files: []string{"README.md"}, + Diff: "+update docs", + } + docMsg := HeuristicCommitMessage(docChanges) + if !strings.HasPrefix(docMsg, "docs:") { + t.Errorf("expected docs: prefix, got: %s", docMsg) + } +} + +func TestBuildCommitPrompt(t *testing.T) { + changes := GitChanges{ + HasChanges: true, + Status: "M internal/ui/model.go", + Diff: "+new feature", + } + prompt := BuildCommitPrompt(changes) + if !strings.Contains(prompt, "Conventional Commit") { + t.Errorf("expected prompt to mention Conventional Commit") + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 0c921d2..0e2d398 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -95,6 +95,9 @@ func NewModel(cfg *config.Config) Model { } client := ai.NewClientFromConfig(cfg) + if cfg.Theme != "" { + _, _ = ApplyTheme(cfg.Theme) + } m := Model{ cfg: cfg, @@ -531,6 +534,8 @@ func (m *Model) handleSlashCommand(input string) (tea.Model, tea.Cmd, bool) { "- `/speak ` — Read text aloud with system TTS\n" + "- `/stats` — Live system telemetry, RAM & CPU dashboard\n" + "- `/model ` — Switch active model dynamically\n" + + "- `/theme [name]` — Switch color themes (`cyberpunk`, `matrix`, `dracula`, `nord`, `tokyo`, `synthwave`)\n" + + "- `/commit [-y]` — AI Git Conventional Commit generator & committer\n" + "- `/save [filename]` — Export conversation to Markdown file\n" + "- `/exec ` or `$ ` — Run shell commands inside Kernel\n" + "- `/quit` or `:q` — Save session history and exit\n" @@ -922,6 +927,126 @@ func (m *Model) handleSlashCommand(input string) (tea.Model, tea.Cmd, bool) { return m, nil, true } + // Color Theme Switcher (/theme [name]) + if cmd == "/theme" { + m.textInput.Reset() + if arg == "" { + m.Messages = append(m.Messages, chatMessage{ + Role: "SYSTEM", + Content: ListThemes(), + Time: time.Now().Format("15:04"), + }) + } else { + palette, err := ApplyTheme(arg) + if err != nil { + m.Messages = append(m.Messages, chatMessage{ + Role: "ERROR", + Content: fmt.Sprintf("Theme '%s' not recognized.\n\n%s", arg, ListThemes()), + Time: time.Now().Format("15:04"), + }) + } else { + m.cfg.Theme = palette.Name + _ = config.SaveConfig(m.cfg) + m.pet.AddXP(10) + m.pet.SetState(pet.StateHappy) + audio.PlayCompanionSound(string(m.pet.Skin)) + m.Messages = append(m.Messages, chatMessage{ + Role: "SYSTEM", + Content: fmt.Sprintf("🎨 Theme set to **%s** (%s)!\nSaved to `~/.kernel/config.json`.", palette.DisplayName, palette.Description), + Time: time.Now().Format("15:04"), + }) + } + } + m.updateViewport() + return m, nil, true + } + + // AI Git Commit Assistant (/commit [-y]) + if cmd == "/commit" { + m.textInput.Reset() + changes := tools.InspectGitChanges() + if !changes.HasChanges { + m.Messages = append(m.Messages, chatMessage{ + Role: "SYSTEM", + Content: "No uncommitted git changes detected. Working tree is clean ✨", + Time: time.Now().Format("15:04"), + }) + m.updateViewport() + return m, nil, true + } + + autoCommit := strings.Contains(arg, "-y") || strings.Contains(arg, "--yes") + + m.Messages = append(m.Messages, chatMessage{ + Role: "YOU", + Content: input, + Time: time.Now().Format("15:04"), + }) + + var commitMsg string + if m.aiClient != nil && m.aiClient.Provider != ai.ProviderDemo { + prompt := tools.BuildCommitPrompt(changes) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + ch := make(chan ai.StreamChunk, 20) + go m.aiClient.Stream(ctx, prompt, ch) + + var sb strings.Builder + for chunk := range ch { + if chunk.Error != nil { + break + } + sb.WriteString(chunk.Text) + } + commitMsg = strings.TrimSpace(sb.String()) + } + + if commitMsg == "" { + commitMsg = tools.HeuristicCommitMessage(changes) + } + + commitMsg = strings.Trim(commitMsg, "`\"'\n\r ") + + fileList := "" + for i, f := range changes.Files { + if i >= 6 { + fileList += fmt.Sprintf("- ... and %d more files\n", len(changes.Files)-6) + break + } + fileList += fmt.Sprintf("- `%s`\n", f) + } + + if autoCommit { + res := tools.RunGitCommit(commitMsg) + if res.ExitCode == 0 { + m.pet.AddXP(25) + m.pet.SetState(pet.StateHappy) + audio.Bark() + m.Messages = append(m.Messages, chatMessage{ + Role: "KERNEL", + Content: fmt.Sprintf("### ⚡ Committed Successfully!\n\n**Commit Message:**\n```\n%s\n```\n\n**Committed Files (%d):**\n%s\n`git commit` exited cleanly (code 0).", commitMsg, len(changes.Files), fileList), + Time: time.Now().Format("15:04"), + }) + } else { + m.Messages = append(m.Messages, chatMessage{ + Role: "ERROR", + Content: fmt.Sprintf("Failed to run git commit: %s\n%s", res.Stderr, res.Stdout), + Time: time.Now().Format("15:04"), + }) + } + } else { + m.pet.AddXP(10) + m.Messages = append(m.Messages, chatMessage{ + Role: "KERNEL", + Content: fmt.Sprintf("### ⚡ AI Suggested Commit\n\n```\n%s\n```\n\n**Changed Files (%d):**\n%s\n\n*Tip:* Run `/commit -y` to stage all and commit immediately, or execute:\n```bash\ngit commit -m \"%s\"\n```", commitMsg, len(changes.Files), fileList, commitMsg), + Time: time.Now().Format("15:04"), + }) + } + + m.updateViewport() + return m, nil, true + } + return m, nil, false } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 4051882..29b41d0 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,102 +1,276 @@ package ui import ( + "fmt" + "strings" + "github.com/charmbracelet/lipgloss" ) +type ThemePalette struct { + Name string + DisplayName string + Description string + Primary string + Secondary string + Accent string + Warning string + Muted string + Danger string + BgDark string + Border string +} + +var AvailableThemes = map[string]ThemePalette{ + "cyberpunk": { + Name: "cyberpunk", + DisplayName: "⚡ Cyberpunk", + Description: "Neon violet & electric cyan", + Primary: "#7D56F4", + Secondary: "#00D7FE", + Accent: "#04B575", + Warning: "#FFB86C", + Muted: "#6272A4", + Danger: "#FF5555", + BgDark: "#13141C", + Border: "#3B4252", + }, + "matrix": { + Name: "matrix", + DisplayName: "🟢 Matrix", + Description: "Phosphor green mainframe", + Primary: "#00FF66", + Secondary: "#00DD44", + Accent: "#33FF33", + Warning: "#CCFF00", + Muted: "#1F6B38", + Danger: "#FF3333", + BgDark: "#0A140D", + Border: "#0D471C", + }, + "dracula": { + Name: "dracula", + DisplayName: "🧛 Dracula", + Description: "Gothic vampire purple & pastel", + Primary: "#BD93F9", + Secondary: "#8BE9FD", + Accent: "#50FA7B", + Warning: "#F1FA8C", + Muted: "#6272A4", + Danger: "#FF5555", + BgDark: "#282A36", + Border: "#44475A", + }, + "nord": { + Name: "nord", + DisplayName: "❄️ Nord", + Description: "Arctic blue & icy aurora", + Primary: "#88C0D0", + Secondary: "#81A1C1", + Accent: "#A3BE8C", + Warning: "#EBCB8B", + Muted: "#4C566A", + Danger: "#BF616A", + BgDark: "#2E3440", + Border: "#3B4252", + }, + "tokyo": { + Name: "tokyo", + DisplayName: "🗼 Tokyo Night", + Description: "Tokyo night storm & lavender", + Primary: "#7AA2F7", + Secondary: "#BB9AF7", + Accent: "#9ECE6A", + Warning: "#E0AF68", + Muted: "#565F89", + Danger: "#F7768E", + BgDark: "#1A1B26", + Border: "#292E42", + }, + "synthwave": { + Name: "synthwave", + DisplayName: "🌆 Synthwave", + Description: "Sunset magenta & neon grid", + Primary: "#FF007F", + Secondary: "#00F0FF", + Accent: "#FFE600", + Warning: "#FF7700", + Muted: "#7957D5", + Danger: "#FF1744", + BgDark: "#18092B", + Border: "#4B0082", + }, +} + var ( + CurrentThemeName = "cyberpunk" + // Colors - PrimaryColor = lipgloss.Color("#7D56F4") // Neon Purple / Violet - SecondaryColor = lipgloss.Color("#00D7FE") // Electric Cyan - AccentColor = lipgloss.Color("#04B575") // Emerald Green - WarningColor = lipgloss.Color("#FFB86C") // Amber / Gold - MutedColor = lipgloss.Color("#6272A4") // Dim Slate - DangerColor = lipgloss.Color("#FF5555") // Bright Red - BgDark = lipgloss.Color("#13141C") - BorderColor = lipgloss.Color("#3B4252") + PrimaryColor lipgloss.TerminalColor + SecondaryColor lipgloss.TerminalColor + AccentColor lipgloss.TerminalColor + WarningColor lipgloss.TerminalColor + MutedColor lipgloss.TerminalColor + DangerColor lipgloss.TerminalColor + BgDark lipgloss.TerminalColor + BorderColor lipgloss.TerminalColor // Header + HeaderBox lipgloss.Style + TitleStyle lipgloss.Style + ProviderBadgeStyle lipgloss.Style + SubtitleStyle lipgloss.Style + + // Badges + UserBadge lipgloss.Style + AgentBadge lipgloss.Style + SystemBadge lipgloss.Style + ErrorBadge lipgloss.Style + TypingDotsStyle lipgloss.Style + CursorStyle lipgloss.Style + LogTimestamp lipgloss.Style + + // Bubbles + MessageBubble lipgloss.Style + StreamingBubble lipgloss.Style + InputBoxActive lipgloss.Style + InputBoxInactive lipgloss.Style + + // Footer + FooterStyle lipgloss.Style + FooterKey lipgloss.Style +) + +func rebuildStyles() { HeaderBox = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(PrimaryColor). - Padding(0, 1) + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(0, 1) TitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(SecondaryColor) + Bold(true). + Foreground(SecondaryColor) ProviderBadgeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(PrimaryColor). - Padding(0, 1) + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(PrimaryColor). + Padding(0, 1) SubtitleStyle = lipgloss.NewStyle(). - Foreground(MutedColor) + Foreground(MutedColor) - // Badges UserBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(SecondaryColor). - Padding(0, 1) + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(SecondaryColor). + Padding(0, 1) AgentBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(PrimaryColor). - Padding(0, 1) + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(PrimaryColor). + Padding(0, 1) SystemBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#1E1E2E")). - Background(WarningColor). - Padding(0, 1) + Bold(true). + Foreground(lipgloss.Color("#1E1E2E")). + Background(WarningColor). + Padding(0, 1) ErrorBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(DangerColor). - Padding(0, 1) + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(DangerColor). + Padding(0, 1) TypingDotsStyle = lipgloss.NewStyle(). - Foreground(SecondaryColor). - Bold(true) + Foreground(SecondaryColor). + Bold(true) CursorStyle = lipgloss.NewStyle(). - Foreground(AccentColor). - Blink(true) + Foreground(AccentColor). + Blink(true) LogTimestamp = lipgloss.NewStyle(). - Foreground(MutedColor) + Foreground(MutedColor) - // Bubbles MessageBubble = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(BorderColor). - Padding(0, 1) + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(BorderColor). + Padding(0, 1) StreamingBubble = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(SecondaryColor). - Padding(0, 1) + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(SecondaryColor). + Padding(0, 1) InputBoxActive = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(PrimaryColor). - Padding(0, 1) + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(PrimaryColor). + Padding(0, 1) InputBoxInactive = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(BorderColor). - Padding(0, 1) + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(BorderColor). + Padding(0, 1) - // Footer FooterStyle = lipgloss.NewStyle(). - Foreground(MutedColor). - MarginTop(1) + Foreground(MutedColor). + MarginTop(1) FooterKey = lipgloss.NewStyle(). - Bold(true). - Foreground(SecondaryColor) -) + Bold(true). + Foreground(SecondaryColor) +} + +// ApplyTheme sets the active UI palette and dynamically updates all styles. +func ApplyTheme(name string) (ThemePalette, error) { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "tokyonight" || key == "tokyo-night" { + key = "tokyo" + } + + palette, exists := AvailableThemes[key] + if !exists { + return AvailableThemes["cyberpunk"], fmt.Errorf("unknown theme '%s'", name) + } + + CurrentThemeName = key + PrimaryColor = lipgloss.Color(palette.Primary) + SecondaryColor = lipgloss.Color(palette.Secondary) + AccentColor = lipgloss.Color(palette.Accent) + WarningColor = lipgloss.Color(palette.Warning) + MutedColor = lipgloss.Color(palette.Muted) + DangerColor = lipgloss.Color(palette.Danger) + BgDark = lipgloss.Color(palette.BgDark) + BorderColor = lipgloss.Color(palette.Border) + + rebuildStyles() + return palette, nil +} + +// ListThemes renders an ASCII preview table of all available themes. +func ListThemes() string { + var b strings.Builder + b.WriteString("### 🎨 Available Kernel Themes\n\n") + b.WriteString("| Theme | Name | Description | Status |\n") + b.WriteString("|---|---|---|---|\n") + + keys := []string{"cyberpunk", "matrix", "dracula", "nord", "tokyo", "synthwave"} + for _, k := range keys { + t := AvailableThemes[k] + status := " " + if k == CurrentThemeName { + status = "★ **ACTIVE**" + } + b.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s |\n", t.Name, t.DisplayName, t.Description, status)) + } + + b.WriteString("\n*Usage:* `/theme ` (e.g. `/theme matrix`, `/theme nord`)") + return b.String() +} + +func init() { + _, _ = ApplyTheme("cyberpunk") +} diff --git a/internal/ui/styles_test.go b/internal/ui/styles_test.go new file mode 100644 index 0000000..79d9cf7 --- /dev/null +++ b/internal/ui/styles_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "testing" +) + +func TestApplyTheme(t *testing.T) { + themes := []string{"cyberpunk", "matrix", "dracula", "nord", "tokyo", "synthwave"} + for _, name := range themes { + palette, err := ApplyTheme(name) + if err != nil { + t.Fatalf("expected theme '%s' to apply without error, got %v", name, err) + } + if palette.Name != name { + t.Errorf("expected palette.Name to be '%s', got '%s'", name, palette.Name) + } + if CurrentThemeName != name { + t.Errorf("expected CurrentThemeName to be '%s', got '%s'", name, CurrentThemeName) + } + } + + // Test aliases + aliasPalette, err := ApplyTheme("tokyo-night") + if err != nil || aliasPalette.Name != "tokyo" { + t.Fatalf("expected tokyo-night alias to resolve to tokyo, got %v", err) + } + + // Test unknown theme + _, err = ApplyTheme("non-existent-theme") + if err == nil { + t.Errorf("expected error for unknown theme, got nil") + } +} + +func TestListThemes(t *testing.T) { + out := ListThemes() + if len(out) == 0 { + t.Fatal("expected ListThemes output to be non-empty") + } +}