first commit
This commit is contained in:
commit
67c8984b95
1
_ref_crush
Submodule
1
_ref_crush
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit aeda508da29bc2f6e22e84c97007c87d83496466
|
||||||
1113
chatbot.go
Normal file
1113
chatbot.go
Normal file
File diff suppressed because it is too large
Load Diff
116
config.go
Normal file
116
config.go
Normal 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
29
go.mod
Normal 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
50
go.sum
Normal 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
159
llm.go
Normal 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
BIN
ludmila-ai-tui
Executable file
Binary file not shown.
36
main.go
Normal file
36
main.go
Normal 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
216
session.go
Normal 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
468
theme.go
Normal 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
BIN
tui-ai-chats
Executable file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user