Matt Low
0384c7cb66
This refactor splits out all conversation concerns into a new `conversation` package. There is now a split between `conversation` and `api`s representation of `Message`, the latter storing the minimum information required for interaction with LLM providers. There is necessary conversation between the two when making LLM calls.
165 lines
3.8 KiB
Go
165 lines
3.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/conversation"
|
|
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
|
|
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/views/chat"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/views/conversations"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/views/settings"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type Model struct {
|
|
App *model.AppModel
|
|
|
|
// window size
|
|
width int
|
|
height int
|
|
|
|
// errors to display
|
|
// TODO: allow dismissing errors
|
|
errs []error
|
|
|
|
activeView shared.View
|
|
views map[shared.View]shared.ViewModel
|
|
}
|
|
|
|
func initialModel(ctx *lmcli.Context, opts LaunchOptions) *Model {
|
|
app := model.NewAppModel(ctx, opts.InitialConversation)
|
|
|
|
m := Model{
|
|
App: app,
|
|
activeView: opts.InitialView,
|
|
views: map[shared.View]shared.ViewModel{
|
|
shared.ViewChat: chat.Chat(app),
|
|
shared.ViewConversations: conversations.Conversations(app),
|
|
shared.ViewSettings: settings.Settings(app),
|
|
},
|
|
}
|
|
|
|
return &m
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
for _, v := range m.views {
|
|
// Init views
|
|
cmds = append(cmds, v.Init())
|
|
}
|
|
cmds = append(cmds, func() tea.Msg {
|
|
// Initial view change
|
|
return shared.MsgViewChange(m.activeView)
|
|
})
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
|
view, cmd := m.views[m.activeView].Update(msg)
|
|
m.views[m.activeView] = view
|
|
if cmd != nil {
|
|
return cmd
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "ctrl+q":
|
|
return tea.Quit
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
case tea.KeyMsg:
|
|
cmd := m.handleGlobalInput(msg)
|
|
if cmd != nil {
|
|
return m, cmd
|
|
}
|
|
case shared.MsgViewChange:
|
|
currView := m.activeView
|
|
m.activeView = shared.View(msg)
|
|
return m, tea.Batch(tea.WindowSize(), shared.ViewEnter(currView))
|
|
case shared.MsgError:
|
|
m.errs = append(m.errs, msg.Err)
|
|
}
|
|
|
|
view, cmd := m.views[m.activeView].Update(msg)
|
|
m.views[m.activeView] = view
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *Model) View() string {
|
|
if m.width == 0 || m.height == 0 {
|
|
// we're dimensionless!
|
|
return ""
|
|
}
|
|
|
|
header := m.views[m.activeView].Header(m.width)
|
|
footer := m.views[m.activeView].Footer(m.width)
|
|
fixedUIHeight := tuiutil.Height(header) + tuiutil.Height(footer)
|
|
|
|
errBanners := make([]string, len(m.errs))
|
|
for idx, err := range m.errs {
|
|
errBanners[idx] = tuiutil.ErrorBanner(err, m.width)
|
|
fixedUIHeight += tuiutil.Height(errBanners[idx])
|
|
}
|
|
|
|
content := m.views[m.activeView].Content(m.width, m.height-fixedUIHeight)
|
|
|
|
sections := make([]string, 0, 4)
|
|
if header != "" {
|
|
sections = append(sections, header)
|
|
}
|
|
if content != "" {
|
|
sections = append(sections, content)
|
|
}
|
|
if footer != "" {
|
|
sections = append(sections, footer)
|
|
}
|
|
for _, errBanner := range errBanners {
|
|
sections = append(sections, errBanner)
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
|
}
|
|
|
|
type LaunchOptions struct {
|
|
InitialConversation *conversation.Conversation
|
|
InitialView shared.View
|
|
}
|
|
|
|
type LaunchOption func(*LaunchOptions)
|
|
|
|
func WithInitialConversation(conv *conversation.Conversation) LaunchOption {
|
|
return func(opts *LaunchOptions) {
|
|
opts.InitialConversation = conv
|
|
}
|
|
}
|
|
|
|
func WithInitialView(view shared.View) LaunchOption {
|
|
return func(opts *LaunchOptions) {
|
|
opts.InitialView = view
|
|
}
|
|
}
|
|
|
|
func Launch(ctx *lmcli.Context, options ...LaunchOption) error {
|
|
opts := &LaunchOptions{
|
|
InitialView: shared.ViewChat,
|
|
}
|
|
for _, opt := range options {
|
|
opt(opts)
|
|
}
|
|
|
|
program := tea.NewProgram(initialModel(ctx, *opts), tea.WithAltScreen())
|
|
if _, err := program.Run(); err != nil {
|
|
return fmt.Errorf("Error running program: %v", err)
|
|
}
|
|
return nil
|
|
}
|