From 9537237a85a850e50a8761dd5493f60431bc7e2f Mon Sep 17 00:00:00 2001 From: "Radu Macocian (admac)" Date: Thu, 25 Jun 2026 14:36:49 +0200 Subject: [PATCH] Added command list --- config/config.go | 37 +++- db/embed/chunker.go | 0 db/history/history.go | 26 ++- go.mod | 6 +- go.sum | 6 + hello.go | 2 +- llm/{chat_client.go => chatclient.go} | 55 ++++- llm/embedder.go | 9 +- model/{Conversation.go => conversation.go} | 2 +- model/{Message.go => message.go} | 0 model/{ToolCall.go => toolcall.go} | 0 ui/chat.go | 230 +++++++++++++++------ ui/command.go | 167 +++++++++++++++ ui/landing.go | 10 +- 14 files changed, 447 insertions(+), 103 deletions(-) delete mode 100644 db/embed/chunker.go rename llm/{chat_client.go => chatclient.go} (72%) rename model/{Conversation.go => conversation.go} (85%) rename model/{Message.go => message.go} (100%) rename model/{ToolCall.go => toolcall.go} (100%) create mode 100644 ui/command.go diff --git a/config/config.go b/config/config.go index 53c7460..8811f23 100644 --- a/config/config.go +++ b/config/config.go @@ -8,9 +8,10 @@ import ( "net" "net/url" "os" + "strconv" ) -var ENV_PREFIX = "SOUV__" +const ENV_PREFIX = "SOUV__" type Config struct { Api ApiConfig @@ -47,6 +48,7 @@ type EmbeddingConfig struct { Key string Model string Dim int + Timeout int BatchSize int } @@ -59,12 +61,14 @@ type HistoryConfig struct { } type ApiConfig struct { - Url string - Key string + Url string + Key string + Timeout int } type LLMConfig struct { - Model string + Model string + TitleModel string } type LogConfig struct { @@ -116,7 +120,7 @@ func getLogTarget(target string) (io.Writer, error) { return os.Stdout, nil case "stderr": return os.Stderr, nil - case "discrd": + case "discard": return io.Discard, nil default: return os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) @@ -126,8 +130,10 @@ func getLogTarget(target string) (io.Writer, error) { func defaultConfig() *Config { return &Config{ ApiConfig{ - Url: "", - Key: ""}, + Url: "", + Key: "", + Timeout: 300000, + }, LLMConfig{Model: ""}, []LogConfig{{Level: slog.LevelInfo.Level(), Format: "text"}}, DbConfig{ @@ -145,6 +151,7 @@ func defaultConfig() *Config { Key: "", Model: "", BatchSize: 64, + Timeout: 60000, }, } } @@ -172,6 +179,22 @@ func overrideFromEnv(cfg *Config) { *override.conf = val } } + durationOverrides := []struct { + conf *int + key string + }{ + {conf: &cfg.Api.Timeout, key: ENV_PREFIX + "api__timeout"}, + {conf: &cfg.Embedding.Timeout, key: ENV_PREFIX + "embedding__timeout"}, + } + for _, override := range durationOverrides { + val := os.Getenv(override.key) + if val != "" { + parsed, err := strconv.Atoi(val) + if err == nil { + *override.conf = parsed + } + } + } } func (cfg Config) validate() error { diff --git a/db/embed/chunker.go b/db/embed/chunker.go deleted file mode 100644 index e69de29..0000000 diff --git a/db/history/history.go b/db/history/history.go index 27215d7..351c1c8 100644 --- a/db/history/history.go +++ b/db/history/history.go @@ -30,7 +30,7 @@ func Init(ctx context.Context, cfg config.DbConfig) (*DbClient, error) { title TEXT, title_source TEXT, summary TEXT, - summary_through_seq int default 0 + summary_through_seq int default 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now() )` const createMessageTable = ` @@ -102,7 +102,7 @@ func Init(ctx context.Context, cfg config.DbConfig) (*DbClient, error) { func (cl DbClient) GetConversation(ctx context.Context, id string) (model.Conversation, error) { cl.logger.Debug("Searching for conversation", "id", id) var conv model.Conversation - if err := cl.pool.QueryRow(ctx, "SELECT id, name, summary FROM conversations WHERE id = $1", id).Scan(&conv.Id, &conv.Name, &conv.Summary); err != nil { + if err := cl.pool.QueryRow(ctx, "SELECT id, title, summary FROM conversations WHERE id = $1", id).Scan(&conv.Id, &conv.Title, &conv.Summary); err != nil { cl.logger.Error("Could not fetch conversation", "id", id, "error", err) return conv, err } @@ -137,7 +137,7 @@ func (cl DbClient) GetConversation(ctx context.Context, id string) (model.Conver func (cl DbClient) GetConversations(ctx context.Context) ([]model.Conversation, error) { cl.logger.Debug("Fetching conversations") - rows, err := cl.pool.Query(ctx, "SELECT id, name, summary FROM conversations") + rows, err := cl.pool.Query(ctx, "SELECT id, title, summary FROM conversations") if err != nil { cl.logger.Error("Could not fetch conversations", "error", err) return []model.Conversation{}, err @@ -146,7 +146,7 @@ func (cl DbClient) GetConversations(ctx context.Context) ([]model.Conversation, conversations := []model.Conversation{} for rows.Next() { var conv model.Conversation - rows.Scan(&conv.Id, &conv.Name, &conv.Summary) + rows.Scan(&conv.Id, &conv.Title, &conv.Summary) conversations = append(conversations, conv) } cl.logger.Debug("Found conversations", "count", len(conversations)) @@ -157,10 +157,15 @@ func (cl DbClient) SaveConversation(ctx context.Context, conv model.Conversation cl.logger.Debug("Saving conversation", "conversation", conv) delta := 0 if conv.Id == "" { - if err := cl.pool.QueryRow(ctx, `INSERT INTO conversations(name, summary) VALUES($1, $2) RETURNING id`, - conv.Name, + if err := cl.pool.QueryRow(ctx, `INSERT INTO conversations(title, summary) VALUES($1, $2) RETURNING id`, + conv.Title, conv.Summary).Scan(&conv.Id); err != nil { - cl.logger.Error("Could not create conversation", "name", conv.Name, "error", err) + cl.logger.Error("Could not create conversation", "title", conv.Title, "error", err) + return conv, err + } + } else { + if _, err := cl.pool.Exec(ctx, `UPDATE conversations SET title = $1, summary = $2 WHERE id = $3`, conv.Title, conv.Summary, conv.Id); err != nil { + cl.logger.Error("Could not update conversation", "id", conv.Id, "error", err) return conv, err } } @@ -215,7 +220,7 @@ func (cl DbClient) saveMessage(ctx context.Context, conversation_id string, mess } if exists { cl.logger.Debug("Message chunk already exists. Skipping") - return message.Id, nil + continue } if _, err := cl.pool.Exec(ctx, `INSERT INTO message_chunks (conversation_id, message_id, content, content_hash) @@ -232,10 +237,13 @@ func (cl DbClient) Close() { } func Chunk(text string, size int, overlap int) []string { + if size <= overlap { + return []string{} + } words := strings.Fields(text) var chunks []string for start := 0; start < len(words); start += size - overlap { - end := min(start + size, len(words)) + end := min(start+size, len(words)) chunks = append(chunks, strings.Join(words[start:end], " ")) if end == len(words) { return chunks diff --git a/go.mod b/go.mod index 28c71fa..a928cb1 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,16 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.3 + github.com/jackc/pgx/v5 v5.4.3 + github.com/pgvector/pgvector-go v0.4.0 + github.com/sahilm/fuzzy v0.1.1 ) require ( github.com/gopsql/db v1.2.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect - github.com/pgvector/pgvector-go v0.4.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect golang.org/x/crypto v0.9.0 // indirect golang.org/x/text v0.9.0 // indirect ) diff --git a/go.sum b/go.sum index 3d8f4d4..afeba71 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,7 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gopsql/db v1.2.1 h1:dzi13FK9OCeV2pARpmn4kSp351Uzg/Le2mEKo43H+EQ= github.com/gopsql/db v1.2.1/go.mod h1:plazrQDxoOfbv749q6AqZyR0wtPak19s4uw8J8pnMYA= @@ -49,6 +50,8 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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= @@ -57,6 +60,7 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4= github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -70,6 +74,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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= @@ -121,4 +126,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hello.go b/hello.go index fab7c39..48df291 100644 --- a/hello.go +++ b/hello.go @@ -19,7 +19,7 @@ func main() { slog.Error("Config error:", "message", err.Error()) os.Exit(1) } - var logger = slog.Default().With("Compoent", "Main") + var logger = slog.Default().With("Component", "Main") logger.Debug("Config initialized") db, err := history.Init(ctx, config.Db) diff --git a/llm/chat_client.go b/llm/chatclient.go similarity index 72% rename from llm/chat_client.go rename to llm/chatclient.go index 8a03cfd..a271192 100644 --- a/llm/chat_client.go +++ b/llm/chatclient.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "strconv" + "time" config "git.estatecloud.org/radumaco/souvenir/config" "git.estatecloud.org/radumaco/souvenir/model" @@ -19,6 +20,7 @@ const MODELS_API = "/v1/models" type ChatClient struct { Cfg config.Config logger slog.Logger + client *http.Client } // Request Types @@ -40,6 +42,11 @@ type ChatResponse struct { Usage Usage `json:"usage"` } +type RenameResponse struct { + Title string `json:"title"` + Summary string `json:"summary"` +} + type Choice struct { Index int `json:"index"` Message model.Message `json:"message"` @@ -56,11 +63,21 @@ func NewLLMClient(cfg config.Config) *ChatClient { return &ChatClient{ Cfg: cfg, logger: *slog.Default().With("Component", "ChatClient"), + client: &http.Client{Timeout: time.Duration(cfg.Api.Timeout) * time.Millisecond}, } } func (cl ChatClient) Models() ([]string, error) { - resp, err := http.Get(cl.Cfg.Api.Url + MODELS_API) + req, err := http.NewRequest("GET", cl.Cfg.Api.Url+MODELS_API, nil) + if err != nil { + cl.logger.Error("There was an error creating the request", "error", err.Error()) + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if cl.Cfg.Api.Key != "" { + req.Header.Set("Authorization", "Bearer "+cl.Cfg.Api.Key) + } + resp, err := cl.client.Do(req) if err != nil { cl.logger.Error("There was an error during the network request", "error", err.Error()) return nil, err @@ -101,12 +118,42 @@ func (cl ChatClient) Models() ([]string, error) { return ret, nil } -func (cl ChatClient) Call(query string, messages []model.Message) ([]model.Message, error) { +func (cl ChatClient) Rename(messages []model.Message) (RenameResponse, error) { + llm := cl.Cfg.Llm.TitleModel + if llm == "" { + llm = cl.Cfg.Llm.Model + } + request := ChatRequest{ + Model: llm, + Messages: append([]model.Message{{ + Role: "system", + Content: "Reply with ONLY JSON with the form: {\"title\":, \"summary\":<summary>}. Title <= 10 words. Summary 2-3 sentances.", + }}, messages...), + } + cl.logger.Debug("Calling rename", "request", request) + out, err := cl.Call(request) + if err != nil { + cl.logger.Error("Error while asking for rename", "error", err) + return RenameResponse{}, err + } + var meta RenameResponse + if err := json.Unmarshal([]byte(out[0].Content), &meta); err != nil { + cl.logger.Error("Could not parse the rename response", "content", out[0].Content) + return meta, err + } + return meta, nil +} + +func (cl ChatClient) Query(query string, messages []model.Message) ([]model.Message, error) { requestBody := ChatRequest{ Model: cl.Cfg.Llm.Model, Messages: messages, Tools: config.AvailableTools(), } + return cl.Call(requestBody) +} + +func (cl ChatClient) Call(requestBody ChatRequest) ([]model.Message, error) { serialized, err := json.Marshal(requestBody) if err != nil { cl.logger.Error("There was an error marshelling the request body", @@ -125,8 +172,8 @@ func (cl ChatClient) Call(query string, messages []model.Message) ([]model.Messa if cl.Cfg.Api.Key != "" { req.Header.Set("Authorization", "Bearer "+cl.Cfg.Api.Key) } - cl.logger.Debug("Executing llm call", "req", req) - resp, err := http.DefaultClient.Do(req) + cl.logger.Debug("Executing llm call") + resp, err := cl.client.Do(req) if err != nil { cl.logger.Error("There was an error during http call", "error", err.Error()) return []model.Message{}, err diff --git a/llm/embedder.go b/llm/embedder.go index a923427..f91078a 100644 --- a/llm/embedder.go +++ b/llm/embedder.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "strings" + "time" config "git.estatecloud.org/radumaco/souvenir/config" ) @@ -19,6 +20,7 @@ type Embedder struct { Cfg config.EmbeddingConfig logger slog.Logger id string + client *http.Client } type EmbedRequest struct { @@ -35,7 +37,7 @@ type EmbedResponse struct { } type Embedding struct { - Embedding []float32 `json:"embeddgin"` + Embedding []float32 `json:"embedding"` Index int `json:"index"` Object string `json:"object"` } @@ -44,6 +46,7 @@ func NewEmbedder(cfg config.EmbeddingConfig) *Embedder { return &Embedder{ Cfg: cfg, logger: *slog.Default().With("Component", "Embedder"), + client: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Millisecond}, } } @@ -55,7 +58,7 @@ func (e Embedder) ID() string { if e.id == "" { e.id = strings.Map(func(r rune) rune { switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r <= '0' && r <= '9': + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': return r default: return '_' @@ -89,7 +92,7 @@ func (e Embedder) EmbedBatch(text []string) ([][]float32, error) { req.Header.Set("Authorization", "Bearer "+e.Cfg.Key) } e.logger.Debug("Executing embedding call", "req", req) - resp, err := http.DefaultClient.Do(req) + resp, err := e.client.Do(req) if err != nil { e.logger.Error("There was an error during http call", "error", err.Error()) return [][]float32{}, err diff --git a/model/Conversation.go b/model/conversation.go similarity index 85% rename from model/Conversation.go rename to model/conversation.go index e4670ee..8619424 100644 --- a/model/Conversation.go +++ b/model/conversation.go @@ -2,7 +2,7 @@ package model type Conversation struct { Id string - Name string + Title string Summary string Messages []Message } diff --git a/model/Message.go b/model/message.go similarity index 100% rename from model/Message.go rename to model/message.go diff --git a/model/ToolCall.go b/model/toolcall.go similarity index 100% rename from model/ToolCall.go rename to model/toolcall.go diff --git a/ui/chat.go b/ui/chat.go index 63cb2c7..7834cde 100644 --- a/ui/chat.go +++ b/ui/chat.go @@ -26,23 +26,25 @@ const ( ) type uiModel struct { - logger slog.Logger - viewport viewport.Model - conversation model.Conversation - textarea textarea.Model - focus focusState - picker picker - senderStyle lipgloss.Style - agentStyle lipgloss.Style - errorStyle lipgloss.Style - client llm.ChatClient - errorMessage string - history history.DbClient - ctx context.Context - width int - height int - waiting bool - err error + logger slog.Logger + viewport viewport.Model + conversation model.Conversation + textarea textarea.Model + focus focusState + picker picker + senderStyle lipgloss.Style + agentStyle lipgloss.Style + errorStyle lipgloss.Style + statusStyle lipgloss.Style + client llm.ChatClient + statusMessage string + commands commandList + history history.DbClient + ctx context.Context + width int + height int + waiting bool + err error } type agentResponseMessage struct { @@ -55,6 +57,12 @@ type conversationSavedMessage struct { err error } +type conversationRenamedMessage struct { + title string + summary string + err error +} + func InitialModel(ctx context.Context, config config.Config, client history.DbClient) uiModel { ta := textarea.New() ta.Placeholder = "Send a message..." @@ -80,7 +88,7 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl ta.KeyMap.InsertNewline.SetEnabled(false) - model := uiModel{ + ui := uiModel{ textarea: ta, conversation: model.Conversation{}, viewport: vp, @@ -94,8 +102,10 @@ func InitialModel(ctx context.Context, config config.Config, client history.DbCl logger: *slog.Default().With("Component", "TUI"), err: nil, } - model.picker = newPicker() - return model + ui.picker = newPicker() + ui.commands = newCommandList(30) + ui.commands.setAvailable(ui.buildCommands()) + return ui } func (m uiModel) Init() tea.Cmd { @@ -113,6 +123,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.picker.SetSize(msg.Width, msg.Height) + m.commands.list.SetSize(msg.Width*80/100, commandDropdownHeight) + if m.commands.open { + m.viewport.SetHeight(m.viewport.Height() - commandDropdownHeight) + } var messages = m.renderMessages() @@ -134,6 +148,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg.String() { case "esc": + if m.commands.open { + m.closeCommands() + return m, nil + } if m.focus != focusChat { m.focus = focusChat } @@ -144,40 +162,75 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "shift+enter", "ctrl+j", "alt+enter": m.textarea.InsertRune('\n') return m, nil - case "enter": - m.clearErrorMessage() - input := m.textarea.Value() - m.textarea.SetValue("") - m.logger.Debug("Received enter. Creating new message", "message", input) - switch input { - case "/history": - m.logger.Debug("Received history call") - m.focus = focusHistory - return m, m.getHistory() - case "/models": - m.logger.Debug("Received models sequence") - m.focus = focusModels - return m, m.getModels() - case "/exit": - m.logger.Debug("Received exit sequence. Quiting") - fmt.Println(m.textarea.Value()) - return m, tea.Quit - default: - m.conversation.Messages = append(m.conversation.Messages, model.Message{ - Content: input, - Role: "user", - Seq: len(m.conversation.Messages) + 1, - }) - var messages = m.renderMessages() - m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) - m.textarea.Reset() - m.viewport.GotoBottom() - m.waiting = true - return m, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation()) + case "up", "down": + if m.commands.open { + var cmd tea.Cmd + m.commands.list, cmd = m.commands.list.Update(msg) + return m, cmd } + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + case "tab": + if m.commands.open { + if sel, ok := m.commands.selectedItem(); ok { + m.textarea.SetValue("/" + sel.name + " ") + } + m.closeCommands() + return m, nil + } + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + case "enter": + m.setStatusMessage("") + input := m.textarea.Value() + + if m.commands.open { + if sel, ok := m.commands.selectedItem(); ok { + m.textarea.SetValue("") + m.closeCommands() + mm, cmd := sel.handler(m) + return mm, cmd + } + } + + if after, ok :=strings.CutPrefix(input, "/"); ok { + name := after + for _, c := range m.commands.available { + if c.name == name { + m.textarea.SetValue("") + m.closeCommands() + mm, cmd := c.handler(m) + return mm, cmd + } + } + } + + m.textarea.SetValue("") + m.closeCommands() + m.logger.Debug("Received enter. Creating new message", "message", input) + + m.conversation.Messages = append(m.conversation.Messages, model.Message{ + Content: input, + Role: "user", + Seq: len(m.conversation.Messages) + 1, + }) + var messages = m.renderMessages() + m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(messages)) + m.textarea.Reset() + m.viewport.GotoBottom() + m.waiting = true + return m, tea.Batch(m.callAgent(input, m.conversation.Messages), m.saveConversation()) default: var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) + v := m.textarea.Value() + if strings.HasPrefix(v, "/") { + m.openCommands() + } else { + m.closeCommands() + } return m, cmd } @@ -187,6 +240,18 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea, cmd = m.textarea.Update(msg) return m, cmd + case conversationRenamedMessage: + m.logger.Debug("Received messageRenamedMessage") + m.waiting = false + if msg.err != nil { + m.logger.Error("There was an error", "error", msg.err) + m.setErrorMessage(msg.err.Error()) + } + m.setStatusMessage("Conversation renamed to: " + msg.title) + m.conversation.Title = msg.title + m.conversation.Summary = msg.summary + return m, nil + case agentResponseMessage: m.logger.Debug("Received agentResponseMessage") m.waiting = false @@ -259,10 +324,12 @@ func (m uiModel) View() tea.View { } parts := []string{m.viewport.View()} - if m.errorMessage != "" { - parts = append(parts, m.errorStyle.Render(m.errorMessage)) + if m.statusMessage != "" { + parts = append(parts, m.statusMessage) + } + if m.commands.open { + parts = append(parts, m.commands.view()) } - parts = append(parts, "model: "+m.client.Cfg.Llm.Model+"; url: "+m.client.Cfg.Api.Url) parts = append(parts, m.textarea.View()) ui := lipgloss.JoinVertical( lipgloss.Left, @@ -270,10 +337,13 @@ func (m uiModel) View() tea.View { c := m.textarea.Cursor() if c != nil { - c.Y += lipgloss.Height(m.viewport.View()) + 1 - if m.errorMessage != "" { + c.Y += lipgloss.Height(m.viewport.View()) + if m.statusMessage != "" { c.Y++ } + if m.commands.open { + c.Y += lipgloss.Height(m.commands.view()) + } gap := m.width - lipgloss.Width(ui) c.X += max(gap/2, 0) } @@ -286,7 +356,7 @@ func (m uiModel) View() tea.View { func (m uiModel) callAgent(input string, messages []model.Message) tea.Cmd { m.logger.Debug("Calling agent", "query", input, "messages", messages) return func() tea.Msg { - resp, err := m.client.Call(input, messages) + resp, err := m.client.Query(input, messages) return agentResponseMessage{response: resp, err: err} } } @@ -299,6 +369,21 @@ func (m uiModel) saveConversation() tea.Cmd { } } +func (m uiModel) renameConversation() tea.Cmd { + m.logger.Debug("Renaming conversation", "conversation", m.conversation) + return func() tea.Msg { + meta, err := m.client.Rename(m.conversation.Messages) + if err != nil { + return conversationRenamedMessage{title: meta.Title, summary: meta.Summary, err: err} + } + m.conversation.Title = meta.Title + m.conversation.Summary = meta.Summary + m.conversation, err = m.history.SaveConversation(m.ctx, m.conversation) + + return conversationRenamedMessage{title: m.conversation.Title, summary: m.conversation.Summary, err: err} + } +} + func (m uiModel) getModels() tea.Cmd { m.logger.Debug("Querying models") return func() tea.Msg { @@ -326,8 +411,8 @@ func (m uiModel) getHistory() tea.Cmd { items := []modelItem{} for _, conv := range resp { name := conv.Id - if conv.Name != "" { - name = conv.Name + if conv.Title != "" { + name = conv.Title } items = append(items, modelItem{name: name, description: conv.Summary}) } @@ -372,15 +457,28 @@ func (m *uiModel) renderMessages() string { } func (m *uiModel) setErrorMessage(message string) { - if m.errorMessage == "" { - m.viewport.SetHeight(m.viewport.Height() - 1) - } - m.errorMessage = message + m.statusMessage = m.errorStyle.Render(message) } -func (m *uiModel) clearErrorMessage() { - if m.errorMessage != "" { - m.viewport.SetHeight(m.viewport.Height() + 1) - m.errorMessage = "" +func (m *uiModel) setStatusMessage(message string) { + if message == "" { + m.statusMessage = m.statusStyle.Render("model: " + m.client.Cfg.Llm.Model + " url: " + m.client.Cfg.Api.Url) + return + } + m.statusMessage = m.statusStyle.Render(message) +} + +func (m *uiModel) openCommands() { + if !m.commands.open { + m.commands.open = true + m.viewport.SetHeight(m.viewport.Height() - commandDropdownHeight) + } + m.commands.filter(strings.TrimPrefix(m.textarea.Value(), "/")) +} + +func (m *uiModel) closeCommands() { + if m.commands.open { + m.commands.open = false + m.viewport.SetHeight(m.viewport.Height() + commandDropdownHeight) } } diff --git a/ui/command.go b/ui/command.go new file mode 100644 index 0000000..afe6f41 --- /dev/null +++ b/ui/command.go @@ -0,0 +1,167 @@ +package ui + +import ( + "fmt" + "io" + "strings" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +const commandDropdownHeight = 8 + +var ( + dropdownStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Padding(0, 1) + + inlineTitleStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("86")).Bold(true) + inlineTitleSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("86")).Bold(true) + inlineDescStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("245")) + inlineDescSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("255")) + inlinePadStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")) + inlinePadSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("60")) +) + +type inlineDelegate struct{} + +func (inlineDelegate) Height() int { return 1 } +func (inlineDelegate) Spacing() int { return 0 } +func (inlineDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (inlineDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + c, ok := item.(command) + if !ok { + return + } + if m.Width() <= 0 { + return + } + title := "/" + c.name + desc := c.description + + titleStyle := inlineTitleStyle + descStyle := inlineDescStyle + padStyle := inlinePadStyle + if index == m.Index() { + titleStyle = inlineTitleSelStyle + descStyle = inlineDescSelStyle + padStyle = inlinePadSelStyle + } + + renderedTitle := titleStyle.Render(title) + renderedDesc := descStyle.Render(desc) + pad := max(m.Width()-1-lipgloss.Width(renderedTitle)-lipgloss.Width(renderedDesc), 1) + fmt.Fprintf(w, "%s%s%s%s", renderedTitle, padStyle.Render(" "), renderedDesc, padStyle.Render(strings.Repeat(" ", pad))) +} + +type command struct { + name string + description string + handler func(m uiModel) (uiModel, tea.Cmd) +} + +func (c command) FilterValue() string { return c.name } +func (c command) Title() string { return "/" + c.name } +func (c command) Description() string { return c.description } + +type commandList struct { + list list.Model + open bool + available []command +} + +func newCommandList(width int) commandList { + l := list.New(nil, inlineDelegate{}, width, 1) + l.Title = "Commands" + l.SetShowFilter(false) + l.SetFilteringEnabled(false) + l.SetShowStatusBar(false) + l.SetShowPagination(false) + l.SetShowHelp(false) + l.DisableQuitKeybindings() + return commandList{list: l} +} + +func (m uiModel) buildCommands() []command { + return []command{ + { + name: "exit", + description: "Quit the application", + handler: func(m uiModel) (uiModel, tea.Cmd) { + m.logger.Debug("Received exit sequence. Quiting") + return m, tea.Quit + }, + }, + { + name: "history", + description: "Open conversation history", + handler: func(m uiModel) (uiModel, tea.Cmd) { + m.logger.Debug("Received history call") + m.focus = focusHistory + return m, m.getHistory() + }, + }, + { + name: "models", + description: "Choose an LLM model", + handler: func(m uiModel) (uiModel, tea.Cmd) { + m.logger.Debug("Received models sequence") + m.focus = focusModels + return m, m.getModels() + }, + }, + { + name: "rename", + description: "Rename the current conversation", + handler: func(m uiModel) (uiModel, tea.Cmd) { + m.logger.Debug("Received rename request") + m.waiting = true + return m, m.renameConversation() + }, + }, + } +} + +func (cl *commandList) setAvailable(cmds []command) { + cl.available = cmds + items := make([]list.Item, len(cmds)) + for i, c := range cmds { + items[i] = c + } + cl.list.SetItems(items) + cl.list.SetHeight(len(items) + 1) +} + +func (cl *commandList) filter(query string) { + var matches []list.Item + if query == "" { + matches = make([]list.Item, len(cl.available)) + for i, c := range cl.available { + matches[i] = c + } + } else { + names := make([]string, len(cl.available)) + for i, c := range cl.available { + names[i] = c.name + } + result := fuzzy.Find(query, names) + matches = make([]list.Item, len(result)) + for i, r := range result { + matches[i] = cl.available[r.Index] + } + } + cl.list.SetItems(matches) + cl.list.ResetSelected() +} + +func (cl commandList) view() string { + return dropdownStyle.Render(cl.list.View()) +} + +func (cl commandList) selectedItem() (command, bool) { + c, ok := cl.list.SelectedItem().(command) + return c, ok +} diff --git a/ui/landing.go b/ui/landing.go index f70e360..a590e9c 100644 --- a/ui/landing.go +++ b/ui/landing.go @@ -38,19 +38,11 @@ const Wordmark = ` \__ \ (_) | |_| |\ V / __/ | | | | | |___/\___/ \__,_| \_/ \___|_| |_|_|_|` -const Commands = ` -/history -/models -/exit -` - func renderLanding(width int, height int) string { return lipgloss.PlaceVertical(height, 0.7, lipgloss.JoinVertical(lipgloss.Center, lipgloss.PlaceHorizontal(width, lipgloss.Center, lipgloss.JoinHorizontal(lipgloss.Center, Logo, Wordmark)), - lipgloss.PlaceHorizontal(width, - lipgloss.Center, - Commands))) + )) }