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