package main import ( "encoding/json" "fmt" "image/color" "os" "path/filepath" "strconv" "strings" lipgloss "charm.land/lipgloss/v2" ) type Token int const ( TokenBg Token = iota TokenFg TokenFgMuted TokenFgMostSubtle TokenPrimary TokenSecondary TokenAccent TokenSuccess TokenSuccessSubtle TokenUser TokenAssistant TokenBorder TokenCodeBlock TokenHeaderBg TokenHeaderFg TokenStreaming TokenBgSubtle TokenOnPrimary ) func tokenKey(t Token) string { switch t { case TokenBg: return "bg" case TokenFg: return "fg" case TokenFgMuted: return "fg_muted" case TokenFgMostSubtle: return "fg_most_subtle" case TokenPrimary: return "primary" case TokenSecondary: return "secondary" case TokenAccent: return "accent" case TokenSuccess: return "success" case TokenSuccessSubtle: return "success_subtle" case TokenUser: return "user" case TokenAssistant: return "assistant" case TokenBorder: return "border" case TokenCodeBlock: return "code_block" case TokenHeaderBg: return "header_bg" case TokenHeaderFg: return "header_fg" case TokenStreaming: return "streaming" case TokenBgSubtle: return "bg_subtle" case TokenOnPrimary: return "on_primary" } return "" } type Theme map[Token]string func SystemTheme() Theme { return Theme{ TokenBg: "#303030", TokenFg: "#d7dae0", TokenFgMuted: "#abb2bf", TokenFgMostSubtle: "#565c64", TokenPrimary: "#56b6c2", TokenSecondary: "#e06c75", TokenAccent: "#c678dd", TokenSuccess: "#98c379", TokenSuccessSubtle: "#98c379", TokenUser: "#56b6c2", TokenAssistant: "#98c379", TokenBorder: "#353b45", TokenCodeBlock: "#353b45", TokenHeaderBg: "#232326", TokenHeaderFg: "#d7dae0", TokenStreaming: "#e06c75", TokenBgSubtle: "#232326", TokenOnPrimary: "#c8ccd4", } } func LoadTheme(name, themesDirectory string) Theme { if name == "" || name == "system" { return SystemTheme() } path := filepath.Join(themesDirectory, name+".json") data, err := os.ReadFile(path) if err != nil { return SystemTheme() } type rawTheme struct { Colors map[string]string `json:"colors"` } var full rawTheme if err := json.Unmarshal(data, &full); err != nil { return SystemTheme() } t := SystemTheme() for k, v := range full.Colors { for tok := TokenBg; tok <= TokenOnPrimary; tok++ { if tokenKey(tok) == k { t[tok] = v break } } } return t } func themeColor(val string) color.Color { if val == "" { return lipgloss.NoColor{} } if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 255 { return lipgloss.Color(strconv.Itoa(n)) } if strings.HasPrefix(val, "#") && len(val) == 7 { return lipgloss.Color(val) } return lipgloss.NoColor{} } var thickLeftBorder = lipgloss.Border{Left: "▌"} type Styles struct { Bg color.Color BgSubtle color.Color Fg color.Color Logo lipgloss.Style LogoDiagonals lipgloss.Style LogoCharm lipgloss.Style LogoVersion lipgloss.Style LogoGradFrom color.Color LogoGradTo color.Color HeaderDiagonals lipgloss.Style HeaderSep lipgloss.Style HeaderWrap lipgloss.Style UserFocused lipgloss.Style UserBlurred lipgloss.Style AsstFocused lipgloss.Style AsstBlurred lipgloss.Style ModelInfoIcon lipgloss.Style ModelInfoName lipgloss.Style ModelInfoProvider lipgloss.Style Help lipgloss.Style Error lipgloss.Style Streaming lipgloss.Style CodeBlock lipgloss.Style InlineBold lipgloss.Style InlineItalic lipgloss.Style InlineCode lipgloss.Style Heading lipgloss.Style H1 lipgloss.Style DialogTitle lipgloss.Style DialogTitleAccent lipgloss.Style DialogView lipgloss.Style DialogSelected lipgloss.Style DialogNormal lipgloss.Style DialogContentBg lipgloss.Style InputBorder lipgloss.Style ScrollbarThumb lipgloss.Style ScrollbarTrack lipgloss.Style ModelPickerBorder color.Color PromptFocused lipgloss.Style PromptBlurred lipgloss.Style BtnFocused lipgloss.Style BtnBlurred lipgloss.Style SelCursor lipgloss.Style SelVisual lipgloss.Style SelCursorBg string SelVisualBg string } func BuildStyles(t Theme) Styles { return Styles{ Bg: themeColor(t[TokenBg]), BgSubtle: themeColor(t[TokenBgSubtle]), Fg: themeColor(t[TokenFg]), Logo: lipgloss.NewStyle(), LogoDiagonals: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])), LogoCharm: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])).Bold(true), LogoVersion: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])), LogoGradFrom: themeColor(t[TokenSecondary]), LogoGradTo: themeColor(t[TokenPrimary]), HeaderDiagonals: lipgloss.NewStyle().Foreground(themeColor(t[TokenPrimary])), HeaderSep: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), HeaderWrap: lipgloss.NewStyle(). Background(themeColor(t[TokenHeaderBg])). Foreground(themeColor(t[TokenHeaderFg])). Padding(0, 1), UserFocused: lipgloss.NewStyle(). Border(thickLeftBorder, false, false, false, true). BorderForeground(themeColor(t[TokenUser])). Foreground(themeColor(t[TokenFgMuted])). Padding(0, 0, 0, 1), UserBlurred: lipgloss.NewStyle(). BorderLeft(true). BorderForeground(themeColor(t[TokenUser])). Foreground(themeColor(t[TokenFgMuted])). Padding(0, 0, 0, 1), AsstFocused: lipgloss.NewStyle(). Border(thickLeftBorder, false, false, false, true). BorderForeground(themeColor(t[TokenAssistant])). Padding(0, 0, 0, 1), AsstBlurred: lipgloss.NewStyle(). BorderLeft(true). BorderForeground(themeColor(t[TokenAssistant])). Padding(0, 0, 0, 1), ModelInfoIcon: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMostSubtle])), ModelInfoName: lipgloss.NewStyle().Foreground(themeColor(t[TokenFg])), ModelInfoProvider: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), Help: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), Error: lipgloss.NewStyle().Foreground(themeColor(t[TokenSecondary])), Streaming: lipgloss.NewStyle().Foreground(themeColor(t[TokenStreaming])), CodeBlock: lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(themeColor(t[TokenCodeBlock])). Background(themeColor(t[TokenBgSubtle])). Padding(0, 1), InlineBold: lipgloss.NewStyle(). Bold(true). Foreground(themeColor(t[TokenFg])), InlineItalic: lipgloss.NewStyle(). Foreground(themeColor(t[TokenSuccess])), InlineCode: lipgloss.NewStyle(). Foreground(themeColor(t[TokenPrimary])). Background(themeColor(t[TokenCodeBlock])), Heading: lipgloss.NewStyle(). Bold(true). Foreground(themeColor(t[TokenPrimary])), H1: lipgloss.NewStyle(). Bold(true). Background(themeColor(t[TokenPrimary])). Foreground(themeColor(t[TokenOnPrimary])), DialogTitle: lipgloss.NewStyle().Padding(0, 1).Foreground(themeColor(t[TokenPrimary])), DialogTitleAccent: lipgloss.NewStyle().Foreground(themeColor(t[TokenSuccess])).Bold(true), DialogView: lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(themeColor(t[TokenPrimary])), DialogSelected: lipgloss.NewStyle().Padding(0, 1). Background(themeColor(t[TokenPrimary])). Foreground(themeColor(t[TokenOnPrimary])), DialogNormal: lipgloss.NewStyle().Padding(0, 1). Foreground(themeColor(t[TokenFg])), DialogContentBg: lipgloss.NewStyle(). Background(themeColor(t[TokenBgSubtle])). Foreground(themeColor(t[TokenFg])). Padding(1, 2), InputBorder: lipgloss.NewStyle(). Background(themeColor(t[TokenBgSubtle])). Padding(2, 3), ScrollbarThumb: lipgloss.NewStyle(). Foreground(themeColor(t[TokenSecondary])), ScrollbarTrack: lipgloss.NewStyle(). Foreground(themeColor(t[TokenFgMuted])), ModelPickerBorder: themeColor(t[TokenPrimary]), PromptFocused: lipgloss.NewStyle().Foreground(themeColor(t[TokenAccent])), PromptBlurred: lipgloss.NewStyle().Foreground(themeColor(t[TokenFgMuted])), BtnFocused: lipgloss.NewStyle().Padding(0, 2). Foreground(themeColor(t[TokenOnPrimary])). Background(themeColor(t[TokenSecondary])), BtnBlurred: lipgloss.NewStyle().Padding(0, 2). Foreground(themeColor(t[TokenFg])). Background(themeColor(t[TokenBgSubtle])), SelCursor: lipgloss.NewStyle(). Background(lipgloss.Color("#2d3f41")), SelVisual: lipgloss.NewStyle(). Background(lipgloss.Color("#3a2540")), SelCursorBg: hexToAnsiBg("#2d3f41"), SelVisualBg: hexToAnsiBg("#3a2540"), } } func hexToAnsiBg(hex string) string { if len(hex) < 7 || hex[0] != '#' { return "" } r, _ := strconv.ParseUint(hex[1:3], 16, 8) g, _ := strconv.ParseUint(hex[3:5], 16, 8) b, _ := strconv.ParseUint(hex[5:7], 16, 8) return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b) } func foregroundGrad(base lipgloss.Style, input string, bold bool, c1, c2 color.Color) []string { if input == "" { return []string{""} } if len(input) == 1 { return []string{base.Foreground(c1).Bold(bold).Render(input)} } runes := []rune(input) ramp := lipgloss.Blend1D(len(runes), c1, c2) result := make([]string, len(runes)) for i, c := range ramp { s := base.Foreground(c) if bold { s = s.Bold(true) } result[i] = s.Render(string(runes[i])) } return result } func applyForegroundGrad(base lipgloss.Style, input string, c1, c2 color.Color) string { clusters := foregroundGrad(base, input, false, c1, c2) return strings.Join(clusters, "") } func applyBoldGrad(base lipgloss.Style, input string, c1, c2 color.Color) string { clusters := foregroundGrad(base, input, true, c1, c2) return strings.Join(clusters, "") } func renderCompactLogo(sty Styles) string { name := applyBoldGrad(sty.Logo, "LUDMILA", sty.LogoGradFrom, sty.LogoGradTo) diag := sty.LogoDiagonals.Render("╱╱╱╱╱") return name + " " + diag } func renderHeader(sty Styles, model string, width int, mode string) string { logoPart := renderCompactLogo(sty) modelStyled := lipgloss.NewStyle(). Foreground(sty.LogoGradTo). Bold(true). Render(" " + model + " ") fixed := lipgloss.Width(logoPart) + 1 + lipgloss.Width(modelStyled) var modePart string if mode != "" { modeBg := lipgloss.Color("#e06c75") modeFg := lipgloss.Color("#c8ccd4") if mode == "--VISUAL--" { modeBg = lipgloss.Color("#c678dd") } modePart = lipgloss.NewStyle(). Background(modeBg). Foreground(modeFg). Bold(true). Padding(0, 1). Render(mode) + " " fixed += lipgloss.Width(modePart) } gap := width - fixed if gap < 0 { gap = 0 } return sty.HeaderWrap.Width(width).Render( modePart + logoPart + " " + modelStyled + strings.Repeat(" ", gap), ) } func wrapText(text string, width int) string { if width <= 0 { return text } var b strings.Builder lines := strings.Split(text, "\n") for li, line := range lines { if li > 0 { b.WriteByte('\n') } runes := []rune(line) pos := 0 for pos < len(runes) { end := pos + width if end > len(runes) { end = len(runes) } b.WriteString(string(runes[pos:end])) pos = end if pos < len(runes) { b.WriteByte('\n') } } } return b.String() } func renderScrollbar(height, contentSize, viewportSize, offset int, thumb, track lipgloss.Style) string { if height <= 0 || contentSize <= viewportSize { return "" } thumbSize := max(1, height*viewportSize/contentSize) maxOffset := contentSize - viewportSize if maxOffset <= 0 { return "" } trackSpace := height - thumbSize thumbPos := 0 if trackSpace > 0 && maxOffset > 0 { thumbPos = min(trackSpace, offset*trackSpace/maxOffset) } var sb strings.Builder for i := range height { if i > 0 { sb.WriteByte('\n') } if i >= thumbPos && i < thumbPos+thumbSize { sb.WriteString(thumb.Render("┃")) } else { sb.WriteString(track.Render("│")) } } return sb.String() } func dialogTitle(title string, gradientAccent lipgloss.Style, width int, from, to color.Color) string { titleRendered := lipgloss.NewStyle().Foreground(from).Render(title) remaining := width - lipgloss.Width(titleRendered) - 1 if remaining < 0 { remaining = 0 } diagLine := applyForegroundGrad(gradientAccent, strings.Repeat("╱", remaining), from, to) return titleRendered + " " + diagLine }