Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
75 changes: 75 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package config

Check failure on line 1 in internal/config/config.go

View workflow job for this annotation

GitHub Actions / lint-build-test

: cannot compile Go 1.23 code (typecheck)

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)
}
112 changes: 112 additions & 0 deletions internal/llm/openai/openai.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package openai

Check failure on line 1 in internal/llm/openai/openai.go

View workflow job for this annotation

GitHub Actions / lint-build-test

: cannot compile Go 1.23 code (typecheck)

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
}
11 changes: 10 additions & 1 deletion internal/ui/components/chat_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import (
tea "github.com/charmbracelet/bubbletea"
)

const (
valueQuit = "quit"
valueExit = "exit"
)

type ChatInputReturnMsg struct {
Value string
}
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions internal/ui/components/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
20 changes: 15 additions & 5 deletions internal/ui/components/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import (
"strings"

"github.com/aavshr/panda/internal/ui/styles"

Check failure on line 6 in internal/ui/components/list.go

View workflow job for this annotation

GitHub Actions / lint-build-test

could not import github.com/aavshr/panda/internal/ui/styles (-: cannot compile Go 1.23 code) (typecheck)
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -37,24 +37,34 @@
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}
}

Expand Down
47 changes: 36 additions & 11 deletions internal/ui/components/message_list_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
"fmt"
"io"

"github.com/aavshr/panda/internal/db"

Check failure on line 7 in internal/ui/components/message_list_item.go

View workflow job for this annotation

GitHub Actions / lint-build-test

could not import github.com/aavshr/panda/internal/db (-: cannot compile Go 1.23 code) (typecheck)
"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
}
Expand All @@ -33,27 +36,46 @@
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
}

Expand All @@ -73,8 +95,11 @@
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(),
}
}
Loading
Loading