diff --git a/go.mod b/go.mod index e05adf1..35e16f4 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( ) require ( + github.com/adrg/xdg v0.5.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4 // indirect @@ -26,6 +27,7 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect + github.com/sashabaranov/go-openai v1.28.2 // indirect golang.design/x/clipboard v0.7.0 // indirect golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect golang.org/x/image v0.6.0 // indirect diff --git a/go.sum b/go.sum index 6db2e81..de47d0f 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= +github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= 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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -51,6 +53,8 @@ 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-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sashabaranov/go-openai v1.28.2 h1:Q3pi34SuNYNN7YrqpHlHbpeYlf75ljgHOAVM/r1yun0= +github.com/sashabaranov/go-openai v1.28.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7d42ae2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/json" + "errors" + "github.com/adrg/xdg" + "os" + "path/filepath" +) + +const ( + appConfigDir = "aavshr-panda" // to not have name conflicts with other apps + configFileName = "config.json" + defaultModel = "gpt-4o-mini" +) + +var ( + ErrConfigNotFound = errors.New("config file not found") +) + +type Config struct { + LLMAPIKey string `json:"llm_api_key"` + LLMModel string `json:"llm_model"` +} + +func GetDir() string { + configDir := xdg.ConfigHome + if configDir == "" { + configDir = filepath.Join(xdg.Home, ".config") + } + return filepath.Join(configDir, appConfigDir) +} + +func GetFilePath() string { + configDir := GetDir() + return filepath.Join(configDir, configFileName) +} + +func Load() (*Config, error) { + configFilePath := GetFilePath() + if _, err := os.Stat(configFilePath); os.IsNotExist(err) { + return nil, ErrConfigNotFound + } + + configFile, err := os.Open(configFilePath) + if err != nil { + return nil, err + } + defer configFile.Close() + + config := &Config{} + if err := json.NewDecoder(configFile).Decode(config); err != nil { + return nil, err + } + return config, nil +} + +func Save(config Config) (*Config, error) { + if config.LLMModel == "" { + config.LLMModel = defaultModel + } + configDir := GetDir() + if err := os.MkdirAll(configDir, 0700); err != nil { + return &config, err + } + + configFilePath := GetFilePath() + configFile, err := os.Create(configFilePath) + if err != nil { + return &config, err + } + defer configFile.Close() + + return &config, json.NewEncoder(configFile).Encode(config) +} diff --git a/internal/llm/openai/openai.go b/internal/llm/openai/openai.go new file mode 100644 index 0000000..974cffb --- /dev/null +++ b/internal/llm/openai/openai.go @@ -0,0 +1,112 @@ +package openai + +import ( + "context" + "errors" + "io" + + client "github.com/sashabaranov/go-openai" +) + +const ( + defaultBaseURL = "https://api.openai.com/v1" +) + +var ( + ErrAPIKeyNotSet = errors.New("API key not set") + ErrNoChoicesReturned = errors.New("no completion returned") + ErrBufferTooSmall = errors.New("buffer too small") +) + +type OpenAIStream struct { + stream *client.ChatCompletionStream +} + +// TODO: verify that I can return EOF before copying the content +// TODO: what if len(p) is too small? +func (s OpenAIStream) Read(p []byte) (int, error) { + resp, err := s.stream.Recv() + if err != nil { + return 0, err + } + if len(resp.Choices) == 0 { + return 0, ErrNoChoicesReturned + } + content := resp.Choices[0].Delta.Content + n := copy(p, content) + if n < len(content) { + return n, ErrBufferTooSmall + } + return n, nil +} + +func (s OpenAIStream) Close() error { + return s.stream.Close() +} + +type OpenAI struct { + baseURL string + apiKey string + client *client.Client +} + +func New(baseURL string) *OpenAI { + if baseURL == "" { + baseURL = defaultBaseURL + } + return &OpenAI{ + baseURL: baseURL, + } +} + +func (o *OpenAI) SetAPIKey(apiKey string) error { + o.apiKey = apiKey + o.client = client.NewClient(o.apiKey) + return nil +} + +func (o *OpenAI) CreateChatCompletion(ctx context.Context, model, input string) (string, error) { + if o.apiKey == "" { + return "", ErrAPIKeyNotSet + } + resp, err := o.client.CreateChatCompletion( + ctx, + client.ChatCompletionRequest{ + Model: model, + Messages: []client.ChatCompletionMessage{ + { + Role: client.ChatMessageRoleUser, + Content: input, + }, + }, + }, + ) + if err != nil { + return "", err + } + if len(resp.Choices) == 0 { + return "", ErrNoChoicesReturned + } + return resp.Choices[0].Message.Content, nil +} + +func (o *OpenAI) CreateChatCompletionStream(ctx context.Context, model, input string) (io.ReadCloser, error) { + if o.apiKey == "" { + return nil, ErrAPIKeyNotSet + } + req := client.ChatCompletionRequest{ + Model: model, + Messages: []client.ChatCompletionMessage{ + { + Role: client.ChatMessageRoleUser, + Content: input, + }, + }, + Stream: true, + } + stream, err := o.client.CreateChatCompletionStream(ctx, req) + if err != nil { + return nil, err + } + return OpenAIStream{stream: stream}, nil +} diff --git a/internal/ui/components/chat_input.go b/internal/ui/components/chat_input.go index 911039a..42dd6de 100644 --- a/internal/ui/components/chat_input.go +++ b/internal/ui/components/chat_input.go @@ -8,6 +8,11 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +const ( + valueQuit = "quit" + valueExit = "exit" +) + type ChatInputReturnMsg struct { Value string } @@ -26,7 +31,7 @@ func NewChatInputModel(width, height int) ChatInputModel { inner.Prompt = "" inner.KeyMap.InsertNewline.SetEnabled(true) - inner.Cursor.SetMode(cursor.CursorStatic) + inner.Cursor.SetMode(cursor.CursorBlink) return ChatInputModel{ inner: inner, @@ -66,6 +71,10 @@ func (c *ChatInputModel) Update(msg tea.Msg) (ChatInputModel, tea.Cmd) { return *c, tea.Quit case tea.KeyTab: value := strings.TrimSpace(c.inner.Value()) + if strings.ToLower(value) == valueQuit || strings.ToLower(value) == valueExit { + return *c, tea.Quit + } + if value != "" { c.inner.Reset() return *c, c.EnterCmd(value) diff --git a/internal/ui/components/components.go b/internal/ui/components/components.go index 0355821..5445d96 100644 --- a/internal/ui/components/components.go +++ b/internal/ui/components/components.go @@ -7,10 +7,12 @@ import ( type Component string const ( - ComponentHistory Component = "history" - ComponentMessages Component = "messages" - ComponentChatInput Component = "chatInput" - ComponentNone Component = "none" // utility component + ComponentHistory Component = "history" + ComponentMessages Component = "messages" + ComponentInnerMessages Component = "innerMessages" + ComponentChatInput Component = "chatInput" + ComponentSettings Component = "settings" + ComponentNone Component = "none" // utility component ) type EscapeMsg struct{} diff --git a/internal/ui/components/list.go b/internal/ui/components/list.go index 29a0d9f..34da0a0 100644 --- a/internal/ui/components/list.go +++ b/internal/ui/components/list.go @@ -37,24 +37,34 @@ type ListModel struct { inner list.Model } -func NewListModel(title string, items []list.Item, width, height int) ListModel { - model := list.New(items, NewMessageListItemDelegate(), width, height) - model.Title = title +type NewListModelInput struct { + Title string + Items []list.Item + Width int + Height int + AllowInfiniteScrolling bool + Delegate list.ItemDelegate +} + +func NewListModel(i *NewListModelInput) ListModel { + model := list.New(i.Items, i.Delegate, i.Width, i.Height) + model.Title = i.Title model.Styles.Title = styles.DefaultListStyle() model.SetShowStatusBar(false) model.SetShowHelp(false) model.FilterInput.Blur() - //model.InfiniteScrolling = true // no item should be selected by default model.Select(-1) model.Styles.NoItems.Padding(0, 0, 1, 2) // TODO: what if title is not plural - model.SetStatusBarItemName(strings.TrimSuffix(strings.ToLower(title), "s"), strings.ToLower(title)) + model.SetStatusBarItemName(strings.TrimSuffix(strings.ToLower(i.Title), "s"), strings.ToLower(i.Title)) // disable quit key model.KeyMap.Quit.SetEnabled(false) + model.InfiniteScrolling = i.AllowInfiniteScrolling + return ListModel{inner: model} } diff --git a/internal/ui/components/message_list_item.go b/internal/ui/components/message_list_item.go index 723cea3..d7d26dd 100644 --- a/internal/ui/components/message_list_item.go +++ b/internal/ui/components/message_list_item.go @@ -5,11 +5,14 @@ import ( "io" "github.com/aavshr/panda/internal/db" + "github.com/aavshr/panda/internal/ui/styles" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/wordwrap" ) -// MessageListItem implements the list.Item, list.DefaultItem and list.ItemDelegate interface +// MessageListItem implements the list.Item, list.DefaultItem interface type MessageListItem struct { message *db.Message } @@ -33,27 +36,46 @@ func (t *MessageListItem) FilterValue() string { return t.Title() } +// MessageListItemDelegate implements list.ItemDelegate interface type MessageListItemDelegate struct { - inner list.DefaultDelegate + userStyle lipgloss.Style + aiStyle lipgloss.Style + metaStyle lipgloss.Style } -// TODO: different rendering for different roles func (d *MessageListItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - d.inner.Render(w, m, index, item) + messageItem, ok := item.(*MessageListItem) + if !ok { + return + } + + contentWidth := m.Width() - 4 // account for padding + + var contentStyle lipgloss.Style + if messageItem.message.Role == "assistant" { + contentStyle = d.aiStyle + } else { + contentStyle = d.userStyle + } + + content := wordwrap.String(messageItem.Title(), contentWidth) + meta := d.metaStyle.Render(messageItem.Description()) + + fmt.Fprintln(w, contentStyle.Render(content)) + fmt.Fprintln(w, meta) + + fmt.Fprintln(w) } func (d *MessageListItemDelegate) Height() int { - // TODO: implement - return 1 + return 2 + d.Spacing() // Minimum for content + metadata + spacing } func (d *MessageListItemDelegate) Spacing() int { - //TODO: implement - return 1 + return 0 } -func (t *MessageListItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - // TODO: implement +func (d *MessageListItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } @@ -73,8 +95,11 @@ func NewMessageListItems(messages []*db.Message) []list.Item { return items } +// NewMessageListItemDelegate maintains the existing API while enhancing functionality func NewMessageListItemDelegate() list.ItemDelegate { return &MessageListItemDelegate{ - inner: list.NewDefaultDelegate(), + userStyle: styles.UserMessageStyle(), + aiStyle: styles.AIMessageStyle(), + metaStyle: styles.MetadataStyle(), } } diff --git a/internal/ui/components/settings.go b/internal/ui/components/settings.go new file mode 100644 index 0000000..f32dcb8 --- /dev/null +++ b/internal/ui/components/settings.go @@ -0,0 +1,59 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type SettingsSubmitMsg struct { + APIKey string +} + +type SettingsModel struct { + inner textinput.Model +} + +func SettingsSubmitCmd(msg SettingsSubmitMsg) tea.Cmd { + return func() tea.Msg { + return msg + } +} + +func NewSettingsModel() SettingsModel { + inner := textinput.New() + inner.Placeholder = "Enter your API key..." + return SettingsModel{inner: inner} +} + +func (m *SettingsModel) Focus() tea.Cmd { + return m.inner.Focus() +} + +func (m *SettingsModel) Blur() { + m.inner.Blur() +} + +func (m *SettingsModel) View() string { + return m.inner.View() +} + +func (m *SettingsModel) Update(msg interface{}) (SettingsModel, tea.Cmd) { + var cmd tea.Cmd + m.inner, cmd = m.inner.Update(msg) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + value := strings.TrimSpace(m.inner.Value()) + if value != "" { + return *m, SettingsSubmitCmd(SettingsSubmitMsg{APIKey: m.inner.Value()}) + } + case tea.KeyEscape, tea.KeyCtrlC, tea.KeyCtrlD: + return *m, tea.Quit + } + } + return *m, cmd +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 88b80b4..769fd03 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -1,12 +1,14 @@ package ui import ( + "context" "errors" "fmt" "io" "slices" "time" + "github.com/aavshr/panda/internal/config" "github.com/aavshr/panda/internal/db" "github.com/aavshr/panda/internal/ui/components" "github.com/aavshr/panda/internal/ui/styles" @@ -95,6 +97,21 @@ func (m *Model) createNewThread(name string) (*db.Thread, error) { return thread, nil } +func (m *Model) handleSettingsSubmitMsg(msg components.SettingsSubmitMsg) tea.Cmd { + savedConfig, err := config.Save(config.Config{ + LLMAPIKey: msg.APIKey, + }) + if err != nil { + return m.cmdError(fmt.Errorf("config.Save: %w", err)) + } + if err := m.llm.SetAPIKey(msg.APIKey); err != nil { + return m.cmdError(fmt.Errorf("llm.SetAPIKey: %w", err)) + } + m.showSettings = false + m.userConfig = savedConfig + return m.Init() +} + func (m *Model) handleChatInputReturnMsg(msg components.ChatInputReturnMsg) tea.Cmd { if m.activeThreadIndex >= len(m.threads) { return m.cmdError(fmt.Errorf("invalid active thread index")) @@ -129,7 +146,8 @@ func (m *Model) handleChatInputReturnMsg(msg components.ChatInputReturnMsg) tea. } m.setMessages(append(m.messages, userMessage)) // TODO: history? - reader, err := m.llm.CreateChatCompletionStream(msg.Value, activeThread.ID) + reader, err := m.llm.CreateChatCompletionStream(context.Background(), + m.userConfig.LLMModel, msg.Value) if err != nil { return m.cmdError(fmt.Errorf("llm.CreateChatCompletionStream: %w", err)) } @@ -203,7 +221,7 @@ func (m *Model) handleListSelectMsg(msg components.ListSelectMsg) tea.Cmd { return nil } -func (m *Model) handleForwardChatCompletionStreamMsg(msg ForwardChatCompletionStreamMsg) tea.Cmd { +func (m *Model) handleForwardChatCompletionStreamMsg(_ ForwardChatCompletionStreamMsg) tea.Cmd { if m.activeThreadIndex >= len(m.threads) { return m.cmdError(fmt.Errorf("invalid active thread index")) } @@ -216,22 +234,22 @@ func (m *Model) handleForwardChatCompletionStreamMsg(msg ForwardChatCompletionSt createdAt := m.messages[llmMessageIndex].CreatedAt // TODO: what buffer size makes it look smooth? - buffer := make([]byte, 8) + buffer := make([]byte, 16) streamDone := false n, err := m.activeLLMStream.Read(buffer) - if err != nil { - // stream is done save message to db if !errors.Is(err, io.EOF) { return m.cmdError(fmt.Errorf("activeLLMStream.Read: %w", err)) - } else { - streamDone = true } + streamDone = true + m.activeLLMStream.Close() } - if n == 0 && !streamDone { - return m.cmdError(fmt.Errorf("activeLLMStream.Read: no bytes read")) - } + /* + if n == 0 && !streamDone { + return m.cmdError(fmt.Errorf("activeLLMStream.Read: no bytes read")) + } + */ if n > 0 { content = fmt.Sprintf("%s%s", content, string(buffer[:n])) // upate created at as soon as first bytes are read diff --git a/internal/ui/llm/llm.go b/internal/ui/llm/llm.go index 680b2fc..5556559 100644 --- a/internal/ui/llm/llm.go +++ b/internal/ui/llm/llm.go @@ -1,13 +1,15 @@ package llm import ( + "context" "io" "strings" ) type LLM interface { - CreateChatCompletion(string, string) (string, error) - CreateChatCompletionStream(string, string) (io.Reader, error) + CreateChatCompletion(context.Context, string, string) (string, error) + CreateChatCompletionStream(context.Context, string, string) (io.ReadCloser, error) + SetAPIKey(string) error } type Mock struct{} @@ -16,10 +18,14 @@ func NewMock() *Mock { return &Mock{} } -func (m *Mock) CreateChatCompletion(model, input string) (string, error) { +func (m *Mock) CreateChatCompletion(ctx context.Context, model, input string) (string, error) { return "this is a mock AI response", nil } -func (m *Mock) CreateChatCompletionStream(model, input string) (io.Reader, error) { - return strings.NewReader("this is a mock AI response.\nwith a new line."), nil +func (m *Mock) CreateChatCompletionStream(ctx context.Context, model, input string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("this is a mock AI response.\nwith a new line.")), nil +} + +func (m *Mock) SetAPIKey(string) error { + return nil } diff --git a/internal/ui/model.go b/internal/ui/model.go index 9dfdea8..98ed4d9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1,9 +1,11 @@ package ui import ( + "errors" "fmt" "io" + "github.com/aavshr/panda/internal/config" "github.com/aavshr/panda/internal/db" "github.com/aavshr/panda/internal/ui/components" "github.com/aavshr/panda/internal/ui/llm" @@ -50,11 +52,14 @@ type Config struct { } type Model struct { - conf *Config + conf *Config + userConfig *config.Config + showSettings bool messagesModel components.ListModel historyModel components.ListModel chatInputModel components.ChatInputModel + settingsModel components.SettingsModel threads []*db.Thread threadsOffset int @@ -62,7 +67,7 @@ type Model struct { messages []*db.Message messagesOffset int - activeLLMStream io.Reader + activeLLMStream io.ReadCloser componentsToContainer map[components.Component]lipgloss.Style focusedComponent components.Component @@ -90,6 +95,19 @@ func New(conf *Config, store store.Store, llm llm.LLM) (*Model, error) { store: store, llm: llm, } + m.settingsModel = components.NewSettingsModel() + userConfig, err := config.Load() + if err != nil { + if !errors.Is(err, config.ErrConfigNotFound) { + return m, fmt.Errorf("config.Load %w", err) + } + m.showSettings = true + } else { + m.userConfig = userConfig + if err := m.llm.SetAPIKey(m.userConfig.LLMAPIKey); err != nil { + return m, fmt.Errorf("llm.SetAPIKey %w", err) + } + } m.activeThreadIndex = 0 m.threads = []*db.Thread{ @@ -109,26 +127,36 @@ func New(conf *Config, store store.Store, llm llm.LLM) (*Model, error) { containerPaddingHeight := 18 containerPaddingWidth := 10 - m.historyModel = components.NewListModel( - titleHistory, - components.NewThreadListItems(m.threads), - conf.historyWidth-containerPaddingWidth, - conf.historyHeight-containerPaddingHeight, - ) + m.historyModel = components.NewListModel(&components.NewListModelInput{ + Title: titleHistory, + Items: components.NewThreadListItems(m.threads), + Width: conf.historyWidth - containerPaddingWidth, + Height: conf.historyHeight - containerPaddingHeight, + Delegate: components.NewThreadListItemDelegate(), + AllowInfiniteScrolling: false, + }) m.historyModel.Select(0) // New Thread is selected by default - m.messagesModel = components.NewListModel( - titleMessages, - components.NewMessageListItems(m.messages), - conf.messagesWidth-containerPaddingWidth, - conf.messagesHeight-containerPaddingHeight, - ) + m.messagesModel = components.NewListModel(&components.NewListModelInput{ + Title: titleMessages, + Items: components.NewMessageListItems(m.messages), + Width: conf.messagesWidth - containerPaddingWidth, + Height: conf.messagesHeight - containerPaddingHeight, + Delegate: components.NewMessageListItemDelegate(), + AllowInfiniteScrolling: false, + }) m.chatInputModel = components.NewChatInputModel(conf.chatInputWidth, conf.chatInputHeight) - container := styles.ContainerStyle() - historyContainer := container.Copy().Width(m.conf.historyWidth).Height(m.conf.historyHeight) - messagesContainer := container.Copy().Width(m.conf.messagesWidth).Height(m.conf.messagesHeight) - chatInputContainer := container.Copy().Width(m.conf.chatInputWidth).Height(m.conf.chatInputHeight) + listContainer := styles.ListContainerStyle() + historyContainer := listContainer.Copy(). + Width(m.conf.historyWidth). + Height(m.conf.historyHeight) + messagesContainer := listContainer.Copy(). + Width(m.conf.messagesWidth). + Height(m.conf.messagesHeight) + chatInputContainer := styles.ContainerStyle(). + Width(m.conf.chatInputWidth). + Height(m.conf.chatInputHeight) m.componentsToContainer = map[components.Component]lipgloss.Style{ components.ComponentHistory: historyContainer, components.ComponentMessages: messagesContainer, @@ -154,6 +182,12 @@ func (m *Model) setActiveThreadIndex(index int) { } func (m *Model) Init() tea.Cmd { + if m.showSettings { + m.focusedComponent = components.ComponentSettings + m.selectedComponent = components.ComponentSettings + return m.settingsModel.Focus() + } + m.settingsModel.Blur() m.focusedComponent = components.ComponentChatInput m.selectedComponent = components.ComponentChatInput return tea.Batch( @@ -165,6 +199,9 @@ func (m *Model) View() string { if m.errorState != nil { return fmt.Sprintf("Error: %v", m.errorState) } + if m.showSettings { + return m.settingsModel.View() + } mainContainer := styles.MainContainerStyle() @@ -181,7 +218,10 @@ func (m *Model) View() string { lipgloss.JoinHorizontal( lipgloss.Top, m.componentsToContainer[components.ComponentHistory].Render(m.historyModel.View()), - m.componentsToContainer[components.ComponentMessages].Render(m.messagesModel.View()), + m.componentsToContainer[components.ComponentMessages].Render( + // the inner container is to enforce max height on the messages list + styles.InnerContainerStyle().MaxHeight(m.conf.messagesHeight).Render(m.messagesModel.View()), + ), ), lipgloss.JoinVertical( lipgloss.Left, @@ -194,6 +234,8 @@ func (m *Model) View() string { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch m.focusedComponent { + case components.ComponentSettings: + m.settingsModel, cmd = m.settingsModel.Update(msg) case components.ComponentHistory: m.historyModel, cmd = m.historyModel.Update(msg) case components.ComponentMessages: @@ -208,6 +250,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case components.SettingsSubmitMsg: + cmd = m.handleSettingsSubmitMsg(msg) case components.ChatInputReturnMsg: cmd = m.handleChatInputReturnMsg(msg) case components.EscapeMsg: diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a22d278..c08e48b 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -11,6 +11,10 @@ const ( ListItemColor = lipgloss.Color("#c6d8d3") ListItemSecondaryColor = lipgloss.Color("#fdf0d5") DescriptionColor = lipgloss.Color("#d81e5b") + UserMessageColor = lipgloss.Color("#5fafaf") + AIMessageColor = lipgloss.Color("#00afff") + MetadataColor = lipgloss.Color("#626262") + messagesLeftPadding = 2 ) func MainContainerStyle() lipgloss.Style { @@ -19,12 +23,23 @@ func MainContainerStyle() lipgloss.Style { return s } +func ListContainerStyle() lipgloss.Style { + s := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), true) + return s +} + func ContainerStyle() lipgloss.Style { s := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true) return s } +func InnerContainerStyle() lipgloss.Style { + return lipgloss.NewStyle().Padding(0) + +} + func SetNormalBorder(s *lipgloss.Style) { s.UnsetBorderForeground() } @@ -74,3 +89,26 @@ func DefaultListItemSecondaryStyle() lipgloss.Style { Foreground(ListItemSecondaryColor) return s } + +func leftPaddedStyle(padding int) lipgloss.Style { + return lipgloss.NewStyle().PaddingLeft(padding) +} + +func UserMessageStyle() lipgloss.Style { + s := leftPaddedStyle(messagesLeftPadding). + Foreground(UserMessageColor) + return s +} + +func AIMessageStyle() lipgloss.Style { + s := leftPaddedStyle(messagesLeftPadding). + Foreground(AIMessageColor) + return s +} + +func MetadataStyle() lipgloss.Style { + s := leftPaddedStyle(messagesLeftPadding). + Foreground(MetadataColor). + Italic(true) + return s +} diff --git a/main.go b/main.go index 37013ea..13d2f0f 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,16 @@ package main import ( _ "embed" + "log" + "os" + "github.com/aavshr/panda/internal/db" + "github.com/aavshr/panda/internal/llm/openai" "github.com/aavshr/panda/internal/ui" - "github.com/aavshr/panda/internal/ui/llm" + //"github.com/aavshr/panda/internal/ui/llm" "github.com/aavshr/panda/internal/ui/store" tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" - "log" - "os" //"log" //"os" //"strings" @@ -70,34 +72,35 @@ func main() { ID: "1", Role: "user", ThreadID: "1", - Content: "Thread 1 Message 1", + Content: "Thread 1\nMessage 1", CreatedAt: "2024-01-01", }, { ID: "2", Role: "assistant", ThreadID: "1", - Content: "Thread 1 Message 2", + Content: "Thread 1\nMessage 2", CreatedAt: "2024-01-02", }, { ID: "3", Role: "user", ThreadID: "2", - Content: "Thread 2 Message 1", + Content: "Thread 2\nMessage 1", CreatedAt: "2024-01-01", }, { ID: "4", Role: "assistant", ThreadID: "2", - Content: "Thread 2 Message 2", + Content: "Thread 2\nMessage 2", CreatedAt: "2024-01-02", }, } mockStore := store.NewMock(testThreads, testMessages) - mockLLM := llm.NewMock() + //mockLLM := llm.NewMock() + openaiLLM := openai.New("") width, height, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { @@ -109,7 +112,7 @@ func main() { MaxThreadsLimit: 100, Width: width - 10, Height: height - 10, - }, mockStore, mockLLM) + }, mockStore, openaiLLM) if err != nil { log.Fatal("ui.New: ", err) }