first commit

This commit is contained in:
Maksim Totmin 2026-06-03 15:05:38 +07:00
commit 67c8984b95
11 changed files with 2188 additions and 0 deletions

1
_ref_crush Submodule

@ -0,0 +1 @@
Subproject commit aeda508da29bc2f6e22e84c97007c87d83496466

1113
chatbot.go Normal file

File diff suppressed because it is too large Load Diff

116
config.go Normal file
View File

@ -0,0 +1,116 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type ProviderConfig struct {
Name string `json:"name,omitempty"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key,omitempty"`
Type string `json:"type,omitempty"`
}
type Config struct {
Theme string `json:"theme,omitempty"`
DefaultProvider string `json:"default_provider,omitempty"`
Providers map[string]ProviderConfig `json:"providers,omitempty"`
}
func configDir() string {
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, ".config")
}
return filepath.Join(dir, "ludmila-ai-tui")
}
func configPath() string {
return filepath.Join(configDir(), "config.json")
}
func themesDir() string {
return filepath.Join(configDir(), "themes")
}
var defaultProviders = map[string]ProviderConfig{
"Mathink": {
Name: "Mathink (FLM)",
BaseURL: "http://localhost:52625",
Type: "openai-compat",
},
}
func ensureConfigDir() error {
dir := configDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir %s: %w", dir, err)
}
if err := os.MkdirAll(themesDir(), 0o755); err != nil {
return fmt.Errorf("create themes dir: %w", err)
}
return nil
}
func LoadConfig() (*Config, error) {
cfg := &Config{
Theme: "system",
Providers: defaultProviders,
}
path := configPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
if mkErr := ensureConfigDir(); mkErr != nil {
return cfg, fmt.Errorf("ensure config dir: %w", mkErr)
}
defaultJSON, _ := json.MarshalIndent(cfg, "", " ")
_ = os.WriteFile(path, defaultJSON, 0o644)
return cfg, nil
}
return cfg, fmt.Errorf("read config %s: %w", path, err)
}
if err := json.Unmarshal(data, cfg); err != nil {
return cfg, fmt.Errorf("parse config %s: %w", path, err)
}
if cfg.Theme == "" {
cfg.Theme = "system"
}
if cfg.Providers == nil || len(cfg.Providers) == 0 {
cfg.Providers = defaultProviders
}
for id, p := range cfg.Providers {
p.APIKey = os.ExpandEnv(p.APIKey)
if p.Type == "" {
p.Type = "openai-compat"
}
cfg.Providers[id] = p
}
var found bool
if cfg.DefaultProvider != "" {
for id := range cfg.Providers {
if id == cfg.DefaultProvider {
found = true
break
}
}
}
if !found {
for id := range cfg.Providers {
cfg.DefaultProvider = id
break
}
}
return cfg, nil
}

29
go.mod Normal file
View File

@ -0,0 +1,29 @@
module tui-ai-chats
go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.3
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)

50
go.sum Normal file
View File

@ -0,0 +1,50 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

159
llm.go Normal file
View File

@ -0,0 +1,159 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
tea "charm.land/bubbletea/v2"
)
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type sseChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
Index int `json:"index"`
Delta chatMessage `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
type tagModel struct {
Name string `json:"name"`
}
type listTagsResp struct {
Models []tagModel `json:"models"`
}
type streamChunkMsg string
type streamDoneMsg struct{}
type streamErrMsg struct{ err error }
func fetchModels(baseURL string) ([]string, error) {
resp, err := http.Get(strings.TrimRight(baseURL, "/") + "/api/tags")
if err != nil {
return nil, fmt.Errorf("failed to fetch models: %w", err)
}
defer resp.Body.Close()
var r listTagsResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("failed to decode models: %w", err)
}
models := make([]string, len(r.Models))
for i, m := range r.Models {
models[i] = m.Name
}
return models, nil
}
func startStream(ctx context.Context, msgs []chatMessage, model, baseURL string) (<-chan tea.Msg, tea.Cmd) {
ch := make(chan tea.Msg, 16)
go runStream(ctx, ch, msgs, model, baseURL)
return ch, readNext(ch)
}
func readNext(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return streamDoneMsg{}
}
return msg
}
}
func runStream(ctx context.Context, ch chan<- tea.Msg, msgs []chatMessage, model, baseURL string) {
defer close(ch)
reqBody := chatRequest{
Model: model,
Messages: msgs,
Stream: true,
}
body, err := json.Marshal(reqBody)
if err != nil {
ch <- streamErrMsg{err: fmt.Errorf("marshal request: %w", err)}
return
}
url := strings.TrimRight(baseURL, "/") + "/v1/chat/completions"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
ch <- streamErrMsg{err: fmt.Errorf("create request: %w", err)}
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() != nil {
return
}
ch <- streamErrMsg{err: fmt.Errorf("http request: %w", err)}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
ch <- streamErrMsg{err: fmt.Errorf("api %d: %s", resp.StatusCode, string(errBody))}
return
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
if ctx.Err() != nil {
return
}
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(line[5:])
if data == "[DONE]" {
break
}
var chunk sseChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
ch <- streamChunkMsg(chunk.Choices[0].Delta.Content)
}
}
if err := scanner.Err(); err != nil {
if ctx.Err() != nil {
return
}
ch <- streamErrMsg{err: fmt.Errorf("scanner: %w", err)}
}
}

BIN
ludmila-ai-tui Executable file

Binary file not shown.

36
main.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
func main() {
cfg, err := LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
theme := LoadTheme(cfg.Theme, themesDir())
sty := BuildStyles(theme)
sessions, err := ListSessions()
if err != nil {
sessions = nil
}
cb, err := newChatbot(cfg.Providers, sessions, sty)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to init: %v\n", err)
os.Exit(1)
}
p := tea.NewProgram(cb)
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

216
session.go Normal file
View File

@ -0,0 +1,216 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"charm.land/bubbles/v2/list"
)
type SessionData struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Title string `json:"title"`
Provider string `json:"provider"`
Model string `json:"model"`
Messages []message `json:"messages"`
History []string `json:"history"`
}
type sessionFile struct {
Session SessionData `json:"session"`
Deleted bool `json:"-"`
}
func currentSession(m *chatbot) *SessionData {
if m.sessionIdx < 0 || m.sessionIdx >= len(m.sessions) {
return nil
}
return &m.sessions[m.sessionIdx]
}
func dataDir() string {
dir := os.Getenv("XDG_DATA_HOME")
if dir == "" {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, ".local", "share")
}
return filepath.Join(dir, "ludmila-ai-tui")
}
func sessionsDir() string {
return filepath.Join(dataDir(), "sessions")
}
func sessionPath(id string) string {
return filepath.Join(sessionsDir(), id+".json")
}
func ListSessions() ([]SessionData, error) {
dir := sessionsDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read sessions dir: %w", err)
}
var sessions []SessionData
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
var file sessionFile
if err := json.Unmarshal(data, &file); err != nil {
continue
}
if file.Deleted {
continue
}
sessions = append(sessions, file.Session)
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt)
})
return sessions, nil
}
func SaveSession(s SessionData) error {
dir := sessionsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create sessions dir: %w", err)
}
s.UpdatedAt = time.Now()
file := sessionFile{Session: s}
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("marshal session: %w", err)
}
if err := os.WriteFile(sessionPath(s.ID), data, 0o644); err != nil {
return fmt.Errorf("write session: %w", err)
}
return nil
}
func DeleteSession(id string) error {
data, err := os.ReadFile(sessionPath(id))
if err != nil {
return fmt.Errorf("read session: %w", err)
}
var file sessionFile
if err := json.Unmarshal(data, &file); err != nil {
return fmt.Errorf("unmarshal session: %w", err)
}
file.Deleted = true
updatedData, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("marshal deleted session: %w", err)
}
return os.WriteFile(sessionPath(id), updatedData, 0o644)
}
func NewSessionData(provider, model string) SessionData {
now := time.Now()
return SessionData{
ID: now.Format("20060102_150405"),
CreatedAt: now,
UpdatedAt: now,
Title: "New session",
Provider: provider,
Model: model,
}
}
func sessionTitle(s SessionData) string {
if s.Title != "" && s.Title != "New session" {
return s.Title
}
return "New session"
}
func (m *chatbot) saveCurrentSession() {
if cs := currentSession(m); cs != nil {
cs.UpdatedAt = time.Now()
cs.Messages = m.messages
cs.History = m.history
cs.Provider = m.currentProvider
cs.Model = m.currentModel
if len(cs.Messages) > 0 && (cs.Title == "" || cs.Title == "New session") {
cs.Title = sessionTitleFromMessages(cs.Messages)
}
m.sessions[m.sessionIdx] = *cs
_ = SaveSession(*cs)
}
}
func sessionTitleFromMessages(msgs []message) string {
if len(msgs) == 0 {
return "New session"
}
title := strings.TrimSpace(msgs[0].Content)
if idx := strings.IndexAny(title, ".\n"); idx > 0 {
title = title[:idx]
}
runes := []rune(title)
if len(runes) > 40 {
title = string(runes[:37]) + "..."
}
return title
}
type sessionItem struct {
session SessionData
active bool
}
func (s sessionItem) Title() string {
prefix := " "
if s.active {
prefix = "* "
}
return prefix + sessionTitle(s.session)
}
func (s sessionItem) Description() string {
parts := []string{
s.session.Provider + "/" + s.session.Model,
"(" + fmt.Sprintf("%d msgs", len(s.session.Messages)) + ")",
s.session.UpdatedAt.Format("Jan 02 15:04"),
}
return strings.Join(parts, " · ")
}
func (s sessionItem) FilterValue() string {
return s.session.Title + " " + s.session.Provider + " " + s.session.Model
}
func buildSessionItems(sessions []SessionData, activeIdx int) []list.Item {
items := make([]list.Item, len(sessions))
for i, s := range sessions {
items[i] = sessionItem{session: s, active: i == activeIdx}
}
return items
}

468
theme.go Normal file
View File

@ -0,0 +1,468 @@
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
}

BIN
tui-ai-chats Executable file

Binary file not shown.