TUI refactor

- Clean up, improved startup logic, initial conversation load
- Moved converation/message business logic (mostly) into `model/tui`
This commit is contained in:
Matt Low 2024-09-16 00:48:45 +00:00
parent 1570988b98
commit 443c8096d3
11 changed files with 431 additions and 350 deletions

View File

@ -6,6 +6,7 @@ import (
cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util" cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util"
"git.mlow.ca/mlow/lmcli/pkg/lmcli" "git.mlow.ca/mlow/lmcli/pkg/lmcli"
"git.mlow.ca/mlow/lmcli/pkg/tui" "git.mlow.ca/mlow/lmcli/pkg/tui"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -19,17 +20,30 @@ func ChatCmd(ctx *lmcli.Context) *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
shortname := ""
if len(args) == 1 { var opts []tui.LaunchOption
shortname = args[0]
list, err := cmd.Flags().GetBool("list")
if err != nil {
return err
} }
if shortname != ""{
_, err := cmdutil.LookupConversationE(ctx, shortname) if !list && len(args) == 1 {
if err != nil { shortname := args[0]
return err if shortname != ""{
conv, err := cmdutil.LookupConversationE(ctx, shortname)
if err != nil {
return err
}
opts = append(opts, tui.WithInitialConversation(conv))
} }
} }
err = tui.Launch(ctx, shortname)
if list {
opts = append(opts, tui.WithInitialView(shared.StateConversations))
}
err = tui.Launch(ctx, opts...)
if err != nil { if err != nil {
return fmt.Errorf("Error fetching LLM response: %v", err) return fmt.Errorf("Error fetching LLM response: %v", err)
} }
@ -43,6 +57,10 @@ func ChatCmd(ctx *lmcli.Context) *cobra.Command {
return ctx.Store.ConversationShortNameCompletions(toComplete), compMode return ctx.Store.ConversationShortNameCompletions(toComplete), compMode
}, },
} }
// -l, --list
cmd.Flags().BoolP("list", "l", false, "View/manage conversations")
applyGenerationFlags(ctx, cmd) applyGenerationFlags(ctx, cmd)
return cmd return cmd
} }

217
pkg/tui/model/model.go Normal file
View File

@ -0,0 +1,217 @@
package model
import (
"context"
"fmt"
"git.mlow.ca/mlow/lmcli/pkg/agents"
"git.mlow.ca/mlow/lmcli/pkg/api"
cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util"
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
)
type LoadedConversation struct {
Conv api.Conversation
LastReply api.Message
}
// AppModel represents the application data model
type AppModel struct {
Ctx *lmcli.Context
Conversations []LoadedConversation
Conversation *api.Conversation
RootMessages []api.Message
Messages []api.Message
}
type MessageCycleDirection int
const (
CycleNext MessageCycleDirection = 1
CyclePrev MessageCycleDirection = -1
)
func (m *AppModel) LoadConversations() (error, []LoadedConversation) {
messages, err := m.Ctx.Store.LatestConversationMessages()
if err != nil {
return shared.MsgError(fmt.Errorf("Could not load conversations: %v", err)), nil
}
conversations := make([]LoadedConversation, len(messages))
for i, msg := range messages {
conversations[i] = LoadedConversation{
Conv: *msg.Conversation,
LastReply: msg,
}
}
return nil, conversations
}
func (a *AppModel) LoadConversationMessages() ([]api.Message, error) {
messages, err := a.Ctx.Store.PathToLeaf(a.Conversation.SelectedRoot)
if err != nil {
return nil, fmt.Errorf("Could not load conversation messages: %v %v", a.Conversation.SelectedRoot, err)
}
return messages, nil
}
func (a *AppModel) GenerateConversationTitle(messages []api.Message) (string, error) {
return cmdutil.GenerateTitle(a.Ctx, messages)
}
func (a *AppModel) UpdateConversationTitle(conversation *api.Conversation) error {
return a.Ctx.Store.UpdateConversation(conversation)
}
func (a *AppModel) CloneMessage(message api.Message, selected bool) (*api.Message, error) {
msg, _, err := a.Ctx.Store.CloneBranch(message)
if err != nil {
return nil, fmt.Errorf("Could not clone message: %v", err)
}
if selected {
if msg.Parent == nil {
msg.Conversation.SelectedRoot = msg
err = a.Ctx.Store.UpdateConversation(msg.Conversation)
} else {
msg.Parent.SelectedReply = msg
err = a.Ctx.Store.UpdateMessage(msg.Parent)
}
if err != nil {
return nil, fmt.Errorf("Could not update selected message: %v", err)
}
}
return msg, nil
}
func (a *AppModel) UpdateMessageContent(message *api.Message) error {
return a.Ctx.Store.UpdateMessage(message)
}
func (a *AppModel) CycleSelectedRoot(conv *api.Conversation, rootMessages []api.Message, dir MessageCycleDirection) (*api.Message, error) {
if len(rootMessages) < 2 {
return nil, nil
}
nextRoot, err := cycleSelectedMessage(conv.SelectedRoot, rootMessages, dir)
if err != nil {
return nil, err
}
conv.SelectedRoot = nextRoot
err = a.Ctx.Store.UpdateConversation(conv)
if err != nil {
return nil, fmt.Errorf("Could not update conversation SelectedRoot: %v", err)
}
return nextRoot, nil
}
func (a *AppModel) CycleSelectedReply(message *api.Message, dir MessageCycleDirection) (*api.Message, error) {
if len(message.Replies) < 2 {
return nil, nil
}
nextReply, err := cycleSelectedMessage(message.SelectedReply, message.Replies, dir)
if err != nil {
return nil, err
}
message.SelectedReply = nextReply
err = a.Ctx.Store.UpdateMessage(message)
if err != nil {
return nil, fmt.Errorf("Could not update message SelectedReply: %v", err)
}
return nextReply, nil
}
func (a *AppModel) PersistConversation(conversation *api.Conversation, messages []api.Message) (*api.Conversation, []api.Message, error) {
var err error
if conversation == nil || conversation.ID == 0 {
conversation, messages, err = a.Ctx.Store.StartConversation(messages...)
if err != nil {
return nil, nil, fmt.Errorf("Could not start new conversation: %v", err)
}
return conversation, messages, nil
}
for i := range messages {
if messages[i].ID > 0 {
err := a.Ctx.Store.UpdateMessage(&messages[i])
if err != nil {
return nil, nil, err
}
} else if i > 0 {
saved, err := a.Ctx.Store.Reply(&messages[i-1], messages[i])
if err != nil {
return nil, nil, err
}
messages[i] = saved[0]
} else {
return nil, nil, fmt.Errorf("Error: no messages to reply to")
}
}
return conversation, messages, nil
}
func (a *AppModel) ExecuteToolCalls(toolCalls []api.ToolCall) ([]api.ToolResult, error) {
agent := a.Ctx.GetAgent(a.Ctx.Config.Defaults.Agent)
if agent == nil {
return nil, fmt.Errorf("Attempted to execute tool calls with no agent configured")
}
return agents.ExecuteToolCalls(toolCalls, agent.Toolbox)
}
func (a *AppModel) PromptLLM(messages []api.Message, chatReplyChunks chan api.Chunk, stopSignal chan struct{}) (*api.Message, error) {
model, provider, err := a.Ctx.GetModelProvider(*a.Ctx.Config.Defaults.Model)
if err != nil {
return nil, err
}
params := api.RequestParameters{
Model: model,
MaxTokens: *a.Ctx.Config.Defaults.MaxTokens,
Temperature: *a.Ctx.Config.Defaults.Temperature,
}
agent := a.Ctx.GetAgent(a.Ctx.Config.Defaults.Agent)
if agent != nil {
params.Toolbox = agent.Toolbox
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-stopSignal:
cancel()
}
}()
return provider.CreateChatCompletionStream(
ctx, params, messages, chatReplyChunks,
)
}
// Helper function
func cycleSelectedMessage(selected *api.Message, choices []api.Message, dir MessageCycleDirection) (*api.Message, error) {
currentIndex := -1
for i, reply := range choices {
if reply.ID == selected.ID {
currentIndex = i
break
}
}
if currentIndex < 0 {
return nil, fmt.Errorf("Selected message %d not found in choices, this is a bug", selected.ID)
}
var next int
if dir == CyclePrev {
next = (currentIndex - 1 + len(choices)) % len(choices)
} else {
next = (currentIndex + 1) % len(choices)
}
return &choices[next], nil
}

View File

@ -1,17 +1,10 @@
package shared package shared
import ( import (
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type Values struct {
ConvShortname string
}
type Shared struct { type Shared struct {
Ctx *lmcli.Context
Values *Values
Width int Width int
Height int Height int
Err error Err error

View File

@ -9,33 +9,42 @@ package tui
import ( import (
"fmt" "fmt"
"git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/lmcli" "git.mlow.ca/mlow/lmcli/pkg/lmcli"
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "git.mlow.ca/mlow/lmcli/pkg/tui/shared"
"git.mlow.ca/mlow/lmcli/pkg/tui/views/chat" "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/conversations"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// Application model type LaunchOptions struct {
type Model struct { InitialConversation *api.Conversation
shared.Shared InitialView shared.View
state shared.View
chat chat.Model
conversations conversations.Model
} }
func initialModel(ctx *lmcli.Context, values shared.Values) Model { type Model struct {
App *model.AppModel
view shared.View
chat chat.Model
conversations conversations.Model
Width int
Height int
}
func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model {
m := Model{ m := Model{
Shared: shared.Shared{ App: &model.AppModel{
Ctx: ctx, Ctx: ctx,
Values: &values, Conversation: opts.InitialConversation,
}, },
view: opts.InitialView,
} }
m.state = shared.StateChat sharedData := shared.Shared{}
m.chat = chat.Chat(m.Shared)
m.conversations = conversations.Conversations(m.Shared) m.chat = chat.Chat(m.App, sharedData)
m.conversations = conversations.Conversations(m.App, sharedData)
return m return m
} }
@ -44,16 +53,14 @@ func (m Model) Init() tea.Cmd {
m.conversations.Init(), m.conversations.Init(),
m.chat.Init(), m.chat.Init(),
func() tea.Msg { func() tea.Msg {
return shared.MsgViewChange(m.state) return shared.MsgViewChange(m.view)
}, },
) )
} }
func (m *Model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) { func (m *Model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) {
// delegate input to the active child state first, only handling it at the
// global level if the child state does not
var cmds []tea.Cmd var cmds []tea.Cmd
switch m.state { switch m.view {
case shared.StateChat: case shared.StateChat:
handled, cmd := m.chat.HandleInput(msg) handled, cmd := m.chat.HandleInput(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@ -88,8 +95,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
case shared.MsgViewChange: case shared.MsgViewChange:
m.state = shared.View(msg) m.view = shared.View(msg)
switch m.state { switch m.view {
case shared.StateChat: case shared.StateChat:
m.chat.HandleResize(m.Width, m.Height) m.chat.HandleResize(m.Width, m.Height)
case shared.StateConversations: case shared.StateConversations:
@ -101,7 +108,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
var cmd tea.Cmd var cmd tea.Cmd
switch m.state { switch m.view {
case shared.StateConversations: case shared.StateConversations:
m.conversations, cmd = m.conversations.Update(msg) m.conversations, cmd = m.conversations.Update(msg)
case shared.StateChat: case shared.StateChat:
@ -115,7 +122,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m Model) View() string { func (m Model) View() string {
switch m.state { switch m.view {
case shared.StateConversations: case shared.StateConversations:
return m.conversations.View() return m.conversations.View()
case shared.StateChat: case shared.StateChat:
@ -124,9 +131,30 @@ func (m Model) View() string {
return "" return ""
} }
func Launch(ctx *lmcli.Context, convShortname string) error { type LaunchOption func(*LaunchOptions)
p := tea.NewProgram(initialModel(ctx, shared.Values{ConvShortname: convShortname}), tea.WithAltScreen())
if _, err := p.Run(); err != nil { func WithInitialConversation(conv *api.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.StateChat,
}
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 fmt.Errorf("Error running program: %v", err)
} }
return nil return nil

View File

@ -4,6 +4,7 @@ import (
"time" "time"
"git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "git.mlow.ca/mlow/lmcli/pkg/tui/shared"
"github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
@ -76,11 +77,11 @@ type Model struct {
shared.Shared shared.Shared
shared.Sections shared.Sections
// app state // App state
App *model.AppModel
// Chat view state
state state // current overall status of the view state state // current overall status of the view
conversation *api.Conversation
rootMessages []api.Message
messages []api.Message
selectedMessage int selectedMessage int
editorTarget editorTarget editorTarget editorTarget
stopSignal chan struct{} stopSignal chan struct{}
@ -88,7 +89,7 @@ type Model struct {
chatReplyChunks chan api.Chunk chatReplyChunks chan api.Chunk
persistence bool // whether we will save new messages in the conversation persistence bool // whether we will save new messages in the conversation
// ui state // UI state
focus focusState focus focusState
wrap bool // whether message content is wrapped to viewport width wrap bool // whether message content is wrapped to viewport width
showToolResults bool // whether tool calls and results are shown showToolResults bool // whether tool calls and results are shown
@ -107,12 +108,12 @@ type Model struct {
elapsed time.Duration elapsed time.Duration
} }
func Chat(shared shared.Shared) Model { func Chat(app *model.AppModel, shared shared.Shared) Model {
m := Model{ m := Model{
App: app,
Shared: shared, Shared: shared,
state: idle, state: idle,
conversation: &api.Conversation{},
persistence: true, persistence: true,
stopSignal: make(chan struct{}), stopSignal: make(chan struct{}),
@ -143,15 +144,15 @@ func Chat(shared shared.Shared) Model {
m.replyCursor.SetChar(" ") m.replyCursor.SetChar(" ")
m.replyCursor.Focus() m.replyCursor.Focus()
system := shared.Ctx.DefaultSystemPrompt() system := app.Ctx.DefaultSystemPrompt()
agent := shared.Ctx.GetAgent(shared.Ctx.Config.Defaults.Agent) agent := app.Ctx.GetAgent(app.Ctx.Config.Defaults.Agent)
if agent != nil && agent.SystemPrompt != "" { if agent != nil && agent.SystemPrompt != "" {
system = agent.SystemPrompt system = agent.SystemPrompt
} }
if system != "" { if system != "" {
m.messages = api.ApplySystemPrompt(m.messages, system, false) m.App.Messages = api.ApplySystemPrompt(m.App.Messages, system, false)
} }
m.input.Focus() m.input.Focus()

View File

@ -1,42 +1,38 @@
package chat package chat
import ( import (
"context"
"errors"
"fmt"
"time" "time"
"git.mlow.ca/mlow/lmcli/pkg/agents"
"git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/api"
cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util" "git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "git.mlow.ca/mlow/lmcli/pkg/tui/shared"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
func (m *Model) setMessage(i int, msg api.Message) { func (m *Model) setMessage(i int, msg api.Message) {
if i >= len(m.messages) { if i >= len(m.App.Messages) {
panic("i out of range") panic("i out of range")
} }
m.messages[i] = msg m.App.Messages[i] = msg
m.messageCache[i] = m.renderMessage(i) m.messageCache[i] = m.renderMessage(i)
} }
func (m *Model) addMessage(msg api.Message) { func (m *Model) addMessage(msg api.Message) {
m.messages = append(m.messages, msg) m.App.Messages = append(m.App.Messages, msg)
m.messageCache = append(m.messageCache, m.renderMessage(len(m.messages)-1)) m.messageCache = append(m.messageCache, m.renderMessage(len(m.App.Messages)-1))
} }
func (m *Model) setMessageContents(i int, content string) { func (m *Model) setMessageContents(i int, content string) {
if i >= len(m.messages) { if i >= len(m.App.Messages) {
panic("i out of range") panic("i out of range")
} }
m.messages[i].Content = content m.App.Messages[i].Content = content
m.messageCache[i] = m.renderMessage(i) m.messageCache[i] = m.renderMessage(i)
} }
func (m *Model) rebuildMessageCache() { func (m *Model) rebuildMessageCache() {
m.messageCache = make([]string, len(m.messages)) m.messageCache = make([]string, len(m.App.Messages))
for i := range m.messages { for i := range m.App.Messages {
m.messageCache[i] = m.renderMessage(i) m.messageCache[i] = m.renderMessage(i)
} }
} }
@ -45,36 +41,15 @@ func (m *Model) updateContent() {
atBottom := m.content.AtBottom() atBottom := m.content.AtBottom()
m.content.SetContent(m.conversationMessagesView()) m.content.SetContent(m.conversationMessagesView())
if atBottom { if atBottom {
// if we were at bottom before the update, scroll with the output
m.content.GotoBottom() m.content.GotoBottom()
} }
} }
func (m *Model) loadConversation(shortname string) tea.Cmd {
return func() tea.Msg {
if shortname == "" {
return nil
}
c, err := m.Shared.Ctx.Store.ConversationByShortName(shortname)
if err != nil {
return shared.MsgError(fmt.Errorf("Could not lookup conversation: %v", err))
}
if c.ID == 0 {
return shared.MsgError(fmt.Errorf("Conversation not found: %s", shortname))
}
rootMessages, err := m.Shared.Ctx.Store.RootMessages(c.ID)
if err != nil {
return shared.MsgError(fmt.Errorf("Could not load conversation root messages: %v\n", err))
}
return msgConversationLoaded{c, rootMessages}
}
}
func (m *Model) loadConversationMessages() tea.Cmd { func (m *Model) loadConversationMessages() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
messages, err := m.Shared.Ctx.Store.PathToLeaf(m.conversation.SelectedRoot) messages, err := m.App.LoadConversationMessages()
if err != nil { if err != nil {
return shared.MsgError(fmt.Errorf("Could not load conversation messages: %v\n", err)) return shared.MsgError(err)
} }
return msgMessagesLoaded(messages) return msgMessagesLoaded(messages)
} }
@ -82,7 +57,7 @@ func (m *Model) loadConversationMessages() tea.Cmd {
func (m *Model) generateConversationTitle() tea.Cmd { func (m *Model) generateConversationTitle() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
title, err := cmdutil.GenerateTitle(m.Shared.Ctx, m.messages) title, err := m.App.GenerateConversationTitle(m.App.Messages)
if err != nil { if err != nil {
return shared.MsgError(err) return shared.MsgError(err)
} }
@ -92,7 +67,7 @@ func (m *Model) generateConversationTitle() tea.Cmd {
func (m *Model) updateConversationTitle(conversation *api.Conversation) tea.Cmd { func (m *Model) updateConversationTitle(conversation *api.Conversation) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err := m.Shared.Ctx.Store.UpdateConversation(conversation) err := m.App.UpdateConversationTitle(conversation)
if err != nil { if err != nil {
return shared.WrapError(err) return shared.WrapError(err)
} }
@ -100,26 +75,11 @@ func (m *Model) updateConversationTitle(conversation *api.Conversation) tea.Cmd
} }
} }
// Clones the given message (and its descendents). If selected is true, updates
// either its parent's SelectedReply or its conversation's SelectedRoot to
// point to the new clone
func (m *Model) cloneMessage(message api.Message, selected bool) tea.Cmd { func (m *Model) cloneMessage(message api.Message, selected bool) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
msg, _, err := m.Ctx.Store.CloneBranch(message) msg, err := m.App.CloneMessage(message, selected)
if err != nil { if err != nil {
return shared.WrapError(fmt.Errorf("Could not clone message: %v", err)) return shared.WrapError(err)
}
if selected {
if msg.Parent == nil {
msg.Conversation.SelectedRoot = msg
err = m.Shared.Ctx.Store.UpdateConversation(msg.Conversation)
} else {
msg.Parent.SelectedReply = msg
err = m.Shared.Ctx.Store.UpdateMessage(msg.Parent)
}
if err != nil {
return shared.WrapError(fmt.Errorf("Could not update selected message: %v", err))
}
} }
return msgMessageCloned(msg) return msgMessageCloned(msg)
} }
@ -127,129 +87,55 @@ func (m *Model) cloneMessage(message api.Message, selected bool) tea.Cmd {
func (m *Model) updateMessageContent(message *api.Message) tea.Cmd { func (m *Model) updateMessageContent(message *api.Message) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err := m.Shared.Ctx.Store.UpdateMessage(message) err := m.App.UpdateMessageContent(message)
if err != nil { if err != nil {
return shared.WrapError(fmt.Errorf("Could not update message: %v", err)) return shared.WrapError(err)
} }
return msgMessageUpdated(message) return msgMessageUpdated(message)
} }
} }
func cycleSelectedMessage(selected *api.Message, choices []api.Message, dir MessageCycleDirection) (*api.Message, error) { func (m *Model) cycleSelectedRoot(conv *api.Conversation, dir model.MessageCycleDirection) tea.Cmd {
currentIndex := -1 if len(m.App.RootMessages) < 2 {
for i, reply := range choices {
if reply.ID == selected.ID {
currentIndex = i
break
}
}
if currentIndex < 0 {
// this should probably be an assert
return nil, fmt.Errorf("Selected message %d not found in choices, this is a bug", selected.ID)
}
var next int
if dir == CyclePrev {
// Wrap around to the last reply if at the beginning
next = (currentIndex - 1 + len(choices)) % len(choices)
} else {
// Wrap around to the first reply if at the end
next = (currentIndex + 1) % len(choices)
}
return &choices[next], nil
}
func (m *Model) cycleSelectedRoot(conv *api.Conversation, dir MessageCycleDirection) tea.Cmd {
if len(m.rootMessages) < 2 {
return nil return nil
} }
return func() tea.Msg { return func() tea.Msg {
nextRoot, err := cycleSelectedMessage(conv.SelectedRoot, m.rootMessages, dir) nextRoot, err := m.App.CycleSelectedRoot(conv, m.App.RootMessages, dir)
if err != nil { if err != nil {
return shared.WrapError(err) return shared.WrapError(err)
} }
conv.SelectedRoot = nextRoot
err = m.Shared.Ctx.Store.UpdateConversation(conv)
if err != nil {
return shared.WrapError(fmt.Errorf("Could not update conversation SelectedRoot: %v", err))
}
return msgSelectedRootCycled(nextRoot) return msgSelectedRootCycled(nextRoot)
} }
} }
func (m *Model) cycleSelectedReply(message *api.Message, dir MessageCycleDirection) tea.Cmd { func (m *Model) cycleSelectedReply(message *api.Message, dir model.MessageCycleDirection) tea.Cmd {
if len(message.Replies) < 2 { if len(message.Replies) < 2 {
return nil return nil
} }
return func() tea.Msg { return func() tea.Msg {
nextReply, err := cycleSelectedMessage(message.SelectedReply, message.Replies, dir) nextReply, err := m.App.CycleSelectedReply(message, dir)
if err != nil { if err != nil {
return shared.WrapError(err) return shared.WrapError(err)
} }
message.SelectedReply = nextReply
err = m.Shared.Ctx.Store.UpdateMessage(message)
if err != nil {
return shared.WrapError(fmt.Errorf("Could not update message SelectedReply: %v", err))
}
return msgSelectedReplyCycled(nextReply) return msgSelectedReplyCycled(nextReply)
} }
} }
func (m *Model) persistConversation() tea.Cmd { func (m *Model) persistConversation() tea.Cmd {
conversation := m.conversation
messages := m.messages
var err error
if conversation.ID == 0 {
return func() tea.Msg {
// Start a new conversation with all messages so far
conversation, messages, err = m.Shared.Ctx.Store.StartConversation(messages...)
if err != nil {
return shared.MsgError(fmt.Errorf("Could not start new conversation: %v", err))
}
return msgConversationPersisted{true, conversation, messages}
}
}
return func() tea.Msg { return func() tea.Msg {
// else, we'll handle updating an existing conversation's messages conversation, messages, err := m.App.PersistConversation(m.App.Conversation, m.App.Messages)
for i := range messages { if err != nil {
if messages[i].ID > 0 { return shared.MsgError(err)
// message has an ID, update it
err := m.Shared.Ctx.Store.UpdateMessage(&messages[i])
if err != nil {
return shared.MsgError(err)
}
} else if i > 0 {
// messages is new, so add it as a reply to previous message
saved, err := m.Shared.Ctx.Store.Reply(&messages[i-1], messages[i])
if err != nil {
return shared.MsgError(err)
}
messages[i] = saved[0]
} else {
// message has no id and no previous messages to add it to
// this shouldn't happen?
return fmt.Errorf("Error: no messages to reply to")
}
} }
return msgConversationPersisted{false, conversation, messages} return msgConversationPersisted{conversation.ID == 0, conversation, messages}
} }
} }
func (m *Model) executeToolCalls(toolCalls []api.ToolCall) tea.Cmd { func (m *Model) executeToolCalls(toolCalls []api.ToolCall) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
agent := m.Shared.Ctx.GetAgent(m.Shared.Ctx.Config.Defaults.Agent) results, err := m.App.ExecuteToolCalls(toolCalls)
if agent == nil {
return shared.MsgError(fmt.Errorf("Attempted to execute tool calls with no agent configured"))
}
results, err := agents.ExecuteToolCalls(toolCalls, agent.Toolbox)
if err != nil { if err != nil {
return shared.MsgError(err) return shared.MsgError(err)
} }
@ -266,38 +152,7 @@ func (m *Model) promptLLM() tea.Cmd {
m.tokenCount = 0 m.tokenCount = 0
return func() tea.Msg { return func() tea.Msg {
model, provider, err := m.Shared.Ctx.GetModelProvider(*m.Shared.Ctx.Config.Defaults.Model) resp, err := m.App.PromptLLM(m.App.Messages, m.chatReplyChunks, m.stopSignal)
if err != nil {
return shared.MsgError(err)
}
params := api.RequestParameters{
Model: model,
MaxTokens: *m.Shared.Ctx.Config.Defaults.MaxTokens,
Temperature: *m.Shared.Ctx.Config.Defaults.Temperature,
}
agent := m.Shared.Ctx.GetAgent(m.Shared.Ctx.Config.Defaults.Agent)
if agent != nil {
params.Toolbox = agent.Toolbox
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-m.stopSignal:
cancel()
}
}()
resp, err := provider.CreateChatCompletionStream(
ctx, params, m.messages, m.chatReplyChunks,
)
if errors.Is(err, context.Canceled) {
return msgChatResponseCanceled(struct{}{})
}
if err != nil { if err != nil {
return msgChatResponseError(err) return msgChatResponseError(err)

View File

@ -5,18 +5,12 @@ import (
"strings" "strings"
"git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "git.mlow.ca/mlow/lmcli/pkg/tui/shared"
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util" tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type MessageCycleDirection int
const (
CycleNext MessageCycleDirection = 1
CyclePrev MessageCycleDirection = -1
)
func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) { func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
switch m.focus { switch m.focus {
case focusInput: case focusInput:
@ -71,17 +65,17 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
m.input.Focus() m.input.Focus()
return true, nil return true, nil
case "e": case "e":
if m.selectedMessage < len(m.messages) { if m.selectedMessage < len(m.App.Messages) {
m.editorTarget = selectedMessage m.editorTarget = selectedMessage
return true, tuiutil.OpenTempfileEditor( return true, tuiutil.OpenTempfileEditor(
"message.*.md", "message.*.md",
m.messages[m.selectedMessage].Content, m.App.Messages[m.selectedMessage].Content,
"# Edit the message below\n", "# Edit the message below\n",
) )
} }
return false, nil return false, nil
case "ctrl+k": case "ctrl+k":
if m.selectedMessage > 0 && len(m.messages) == len(m.messageOffsets) { if m.selectedMessage > 0 && len(m.App.Messages) == len(m.messageOffsets) {
m.selectedMessage-- m.selectedMessage--
m.updateContent() m.updateContent()
offset := m.messageOffsets[m.selectedMessage] offset := m.messageOffsets[m.selectedMessage]
@ -89,7 +83,7 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
} }
return true, nil return true, nil
case "ctrl+j": case "ctrl+j":
if m.selectedMessage < len(m.messages)-1 && len(m.messages) == len(m.messageOffsets) { if m.selectedMessage < len(m.App.Messages)-1 && len(m.App.Messages) == len(m.messageOffsets) {
m.selectedMessage++ m.selectedMessage++
m.updateContent() m.updateContent()
offset := m.messageOffsets[m.selectedMessage] offset := m.messageOffsets[m.selectedMessage]
@ -97,23 +91,23 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) {
} }
return true, nil return true, nil
case "ctrl+h", "ctrl+l": case "ctrl+h", "ctrl+l":
dir := CyclePrev dir := model.CyclePrev
if msg.String() == "ctrl+l" { if msg.String() == "ctrl+l" {
dir = CycleNext dir = model.CycleNext
} }
var cmd tea.Cmd var cmd tea.Cmd
if m.selectedMessage == 0 { if m.selectedMessage == 0 {
cmd = m.cycleSelectedRoot(m.conversation, dir) cmd = m.cycleSelectedRoot(m.App.Conversation, dir)
} else if m.selectedMessage > 0 { } else if m.selectedMessage > 0 {
cmd = m.cycleSelectedReply(&m.messages[m.selectedMessage-1], dir) cmd = m.cycleSelectedReply(&m.App.Messages[m.selectedMessage-1], dir)
} }
return cmd != nil, cmd return cmd != nil, cmd
case "ctrl+r": case "ctrl+r":
// resubmit the conversation with all messages up until and including the selected message // resubmit the conversation with all messages up until and including the selected message
if m.state == idle && m.selectedMessage < len(m.messages) { if m.state == idle && m.selectedMessage < len(m.App.Messages) {
m.messages = m.messages[:m.selectedMessage+1] m.App.Messages = m.App.Messages[:m.selectedMessage+1]
m.messageCache = m.messageCache[:m.selectedMessage+1] m.messageCache = m.messageCache[:m.selectedMessage+1]
cmd := m.promptLLM() cmd := m.promptLLM()
m.updateContent() m.updateContent()
@ -129,9 +123,9 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
m.focus = focusMessages m.focus = focusMessages
if len(m.messages) > 0 { if len(m.App.Messages) > 0 {
if m.selectedMessage < 0 || m.selectedMessage >= len(m.messages) { if m.selectedMessage < 0 || m.selectedMessage >= len(m.App.Messages) {
m.selectedMessage = len(m.messages) - 1 m.selectedMessage = len(m.App.Messages) - 1
} }
offset := m.messageOffsets[m.selectedMessage] offset := m.messageOffsets[m.selectedMessage]
tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2)
@ -150,7 +144,7 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) {
return true, nil return true, nil
} }
if len(m.messages) > 0 && m.messages[len(m.messages)-1].Role == api.MessageRoleUser { if len(m.App.Messages) > 0 && m.App.Messages[len(m.App.Messages)-1].Role == api.MessageRoleUser {
return true, shared.WrapError(fmt.Errorf("Can't reply to a user message")) return true, shared.WrapError(fmt.Errorf("Can't reply to a user message"))
} }

View File

@ -15,7 +15,7 @@ func (m *Model) HandleResize(width, height int) {
m.Width, m.Height = width, height m.Width, m.Height = width, height
m.content.Width = width m.content.Width = width
m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize()) m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize())
if len(m.messages) > 0 { if len(m.App.Messages) > 0 {
m.rebuildMessageCache() m.rebuildMessageCache()
m.updateContent() m.updateContent()
} }
@ -36,26 +36,21 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// wake up spinners and cursors // wake up spinners and cursors
cmds = append(cmds, cursor.Blink, m.spinner.Tick) cmds = append(cmds, cursor.Blink, m.spinner.Tick)
if m.Shared.Values.ConvShortname != "" { // Refresh view
// (re)load conversation contents
cmds = append(cmds, m.loadConversation(m.Shared.Values.ConvShortname))
if m.conversation.ShortName.String != m.Shared.Values.ConvShortname {
// clear existing messages if we're loading a new conversation
m.messages = []api.Message{}
m.selectedMessage = 0
}
}
m.rebuildMessageCache() m.rebuildMessageCache()
m.updateContent() m.updateContent()
if m.App.Conversation != nil && m.App.Conversation.ID > 0 {
// (re)load conversation contents
cmds = append(cmds, m.loadConversationMessages())
}
case tuiutil.MsgTempfileEditorClosed: case tuiutil.MsgTempfileEditorClosed:
contents := string(msg) contents := string(msg)
switch m.editorTarget { switch m.editorTarget {
case input: case input:
m.input.SetValue(contents) m.input.SetValue(contents)
case selectedMessage: case selectedMessage:
toEdit := m.messages[m.selectedMessage] toEdit := m.App.Messages[m.selectedMessage]
if toEdit.Content != contents { if toEdit.Content != contents {
toEdit.Content = contents toEdit.Content = contents
m.setMessage(m.selectedMessage, toEdit) m.setMessage(m.selectedMessage, toEdit)
@ -66,18 +61,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
} }
case msgConversationLoaded: case msgConversationLoaded:
m.conversation = msg.conversation m.App.Conversation = msg.conversation
m.rootMessages = msg.rootMessages m.App.RootMessages = msg.rootMessages
m.selectedMessage = -1 m.selectedMessage = -1
if len(m.rootMessages) > 0 { if len(m.App.RootMessages) > 0 {
cmds = append(cmds, m.loadConversationMessages()) cmds = append(cmds, m.loadConversationMessages())
} }
case msgMessagesLoaded: case msgMessagesLoaded:
m.messages = msg m.App.Messages = msg
if m.selectedMessage == -1 { if m.selectedMessage == -1 {
m.selectedMessage = len(msg) - 1 m.selectedMessage = len(msg) - 1
} else { } else {
m.selectedMessage = min(m.selectedMessage, len(m.messages)) m.selectedMessage = min(m.selectedMessage, len(m.App.Messages))
} }
m.rebuildMessageCache() m.rebuildMessageCache()
m.updateContent() m.updateContent()
@ -88,10 +83,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
break break
} }
last := len(m.messages) - 1 last := len(m.App.Messages) - 1
if last >= 0 && m.messages[last].Role.IsAssistant() { if last >= 0 && m.App.Messages[last].Role.IsAssistant() {
// append chunk to existing message // append chunk to existing message
m.setMessageContents(last, m.messages[last].Content+msg.Content) m.setMessageContents(last, m.App.Messages[last].Content+msg.Content)
} else { } else {
// use chunk in a new message // use chunk in a new message
m.addMessage(api.Message{ m.addMessage(api.Message{
@ -113,12 +108,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
reply := (*api.Message)(msg) reply := (*api.Message)(msg)
reply.Content = strings.TrimSpace(reply.Content) reply.Content = strings.TrimSpace(reply.Content)
last := len(m.messages) - 1 last := len(m.App.Messages) - 1
if last < 0 { if last < 0 {
panic("Unexpected empty messages handling msgAssistantReply") panic("Unexpected empty messages handling msgAssistantReply")
} }
if m.messages[last].Role.IsAssistant() { if m.App.Messages[last].Role.IsAssistant() {
// TODO: handle continuations gracefully - some models support them well, others fail horribly. // TODO: handle continuations gracefully - some models support them well, others fail horribly.
m.setMessage(last, *reply) m.setMessage(last, *reply)
} else { } else {
@ -136,7 +131,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
cmds = append(cmds, m.persistConversation()) cmds = append(cmds, m.persistConversation())
} }
if m.conversation.Title == "" { if m.App.Conversation.Title == "" {
cmds = append(cmds, m.generateConversationTitle()) cmds = append(cmds, m.generateConversationTitle())
} }
@ -149,12 +144,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.Shared.Err = error(msg) m.Shared.Err = error(msg)
m.updateContent() m.updateContent()
case msgToolResults: case msgToolResults:
last := len(m.messages) - 1 last := len(m.App.Messages) - 1
if last < 0 { if last < 0 {
panic("Unexpected empty messages handling msgAssistantReply") panic("Unexpected empty messages handling msgAssistantReply")
} }
if m.messages[last].Role != api.MessageRoleToolCall { if m.App.Messages[last].Role != api.MessageRoleToolCall {
panic("Previous message not a tool call, unexpected") panic("Previous message not a tool call, unexpected")
} }
@ -170,29 +165,29 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.updateContent() m.updateContent()
case msgConversationTitleGenerated: case msgConversationTitleGenerated:
title := string(msg) title := string(msg)
m.conversation.Title = title m.App.Conversation.Title = title
if m.persistence { if m.persistence {
cmds = append(cmds, m.updateConversationTitle(m.conversation)) cmds = append(cmds, m.updateConversationTitle(m.App.Conversation))
} }
case cursor.BlinkMsg: case cursor.BlinkMsg:
if m.state == pendingResponse { if m.state == pendingResponse {
// ensure we show the updated "wait for response" cursor blink state // ensure we show the updated "wait for response" cursor blink state
last := len(m.messages)-1 last := len(m.App.Messages) - 1
m.messageCache[last] = m.renderMessage(last) m.messageCache[last] = m.renderMessage(last)
m.updateContent() m.updateContent()
} }
case msgConversationPersisted: case msgConversationPersisted:
m.conversation = msg.conversation m.App.Conversation = msg.conversation
m.messages = msg.messages m.App.Messages = msg.messages
if msg.isNew { if msg.isNew {
m.rootMessages = []api.Message{m.messages[0]} m.App.RootMessages = []api.Message{m.App.Messages[0]}
} }
m.rebuildMessageCache() m.rebuildMessageCache()
m.updateContent() m.updateContent()
case msgMessageCloned: case msgMessageCloned:
if msg.Parent == nil { if msg.Parent == nil {
m.conversation = msg.Conversation m.App.Conversation = msg.Conversation
m.rootMessages = append(m.rootMessages, *msg) m.App.RootMessages = append(m.App.RootMessages, *msg)
} }
cmds = append(cmds, m.loadConversationMessages()) cmds = append(cmds, m.loadConversationMessages())
case msgSelectedRootCycled, msgSelectedReplyCycled, msgMessageUpdated: case msgSelectedRootCycled, msgSelectedReplyCycled, msgMessageUpdated:

View File

@ -88,26 +88,26 @@ func (m *Model) renderMessageHeading(i int, message *api.Message) string {
faint := lipgloss.NewStyle().Faint(true) faint := lipgloss.NewStyle().Faint(true)
if i == 0 && len(m.rootMessages) > 1 && m.conversation.SelectedRootID != nil { if i == 0 && len(m.App.RootMessages) > 1 && m.App.Conversation.SelectedRootID != nil {
selectedRootIndex := 0 selectedRootIndex := 0
for j, reply := range m.rootMessages { for j, reply := range m.App.RootMessages {
if reply.ID == *m.conversation.SelectedRootID { if reply.ID == *m.App.Conversation.SelectedRootID {
selectedRootIndex = j selectedRootIndex = j
break break
} }
} }
suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedRootIndex+1, len(m.rootMessages))) suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedRootIndex+1, len(m.App.RootMessages)))
} }
if i > 0 && len(m.messages[i-1].Replies) > 1 { if i > 0 && len(m.App.Messages[i-1].Replies) > 1 {
// Find the selected reply index // Find the selected reply index
selectedReplyIndex := 0 selectedReplyIndex := 0
for j, reply := range m.messages[i-1].Replies { for j, reply := range m.App.Messages[i-1].Replies {
if reply.ID == *m.messages[i-1].SelectedReplyID { if reply.ID == *m.App.Messages[i-1].SelectedReplyID {
selectedReplyIndex = j selectedReplyIndex = j
break break
} }
} }
suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.messages[i-1].Replies))) suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.App.Messages[i-1].Replies)))
} }
if i == m.selectedMessage { if i == m.selectedMessage {
@ -127,20 +127,20 @@ func (m *Model) renderMessageHeading(i int, message *api.Message) string {
// *at this moment* - we render differently depending on the current application // *at this moment* - we render differently depending on the current application
// state (window size, etc, etc). // state (window size, etc, etc).
func (m *Model) renderMessage(i int) string { func (m *Model) renderMessage(i int) string {
msg := &m.messages[i] msg := &m.App.Messages[i]
// Write message contents // Write message contents
sb := &strings.Builder{} sb := &strings.Builder{}
sb.Grow(len(msg.Content) * 2) sb.Grow(len(msg.Content) * 2)
if msg.Content != "" { if msg.Content != "" {
err := m.Shared.Ctx.Chroma.Highlight(sb, msg.Content) err := m.App.Ctx.Chroma.Highlight(sb, msg.Content)
if err != nil { if err != nil {
sb.Reset() sb.Reset()
sb.WriteString(msg.Content) sb.WriteString(msg.Content)
} }
} }
isLast := i == len(m.messages)-1 isLast := i == len(m.App.Messages)-1
isAssistant := msg.Role == api.MessageRoleAssistant isAssistant := msg.Role == api.MessageRoleAssistant
if m.state == pendingResponse && isLast && isAssistant { if m.state == pendingResponse && isLast && isAssistant {
@ -204,7 +204,7 @@ func (m *Model) renderMessage(i int) string {
if msg.Content != "" { if msg.Content != "" {
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
_ = m.Shared.Ctx.Chroma.HighlightLang(sb, toolString, "yaml") _ = m.App.Ctx.Chroma.HighlightLang(sb, toolString, "yaml")
} }
content := strings.TrimRight(sb.String(), "\n") content := strings.TrimRight(sb.String(), "\n")
@ -224,9 +224,9 @@ func (m *Model) renderMessage(i int) string {
func (m *Model) conversationMessagesView() string { func (m *Model) conversationMessagesView() string {
sb := strings.Builder{} sb := strings.Builder{}
m.messageOffsets = make([]int, len(m.messages)) m.messageOffsets = make([]int, len(m.App.Messages))
lineCnt := 1 lineCnt := 1
for i, message := range m.messages { for i, message := range m.App.Messages {
m.messageOffsets[i] = lineCnt m.messageOffsets[i] = lineCnt
heading := m.renderMessageHeading(i, &message) heading := m.renderMessageHeading(i, &message)
@ -241,7 +241,7 @@ func (m *Model) conversationMessagesView() string {
} }
// Render a placeholder for the incoming assistant reply // Render a placeholder for the incoming assistant reply
if m.state == pendingResponse && m.messages[len(m.messages)-1].Role != api.MessageRoleAssistant { if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant {
heading := m.renderMessageHeading(-1, &api.Message{ heading := m.renderMessageHeading(-1, &api.Message{
Role: api.MessageRoleAssistant, Role: api.MessageRoleAssistant,
}) })
@ -257,8 +257,8 @@ func (m *Model) conversationMessagesView() string {
func (m *Model) headerView() string { func (m *Model) headerView() string {
titleStyle := lipgloss.NewStyle().Bold(true) titleStyle := lipgloss.NewStyle().Bold(true)
var title string var title string
if m.conversation != nil && m.conversation.Title != "" { if m.App.Conversation != nil && m.App.Conversation.Title != "" {
title = m.conversation.Title title = m.App.Conversation.Title
} else { } else {
title = "Untitled" title = "Untitled"
} }
@ -298,7 +298,7 @@ func (m *Model) footerView() string {
rightSegments = append(rightSegments, segmentStyle.Render(throughput)) rightSegments = append(rightSegments, segmentStyle.Render(throughput))
} }
model := fmt.Sprintf("Model: %s", *m.Shared.Ctx.Config.Defaults.Model) model := fmt.Sprintf("Model: %s", *m.App.Ctx.Config.Defaults.Model)
rightSegments = append(rightSegments, segmentStyle.Render(model)) rightSegments = append(rightSegments, segmentStyle.Render(model))
left := strings.Join(leftSegments, segmentSeparator) left := strings.Join(leftSegments, segmentSeparator)

View File

@ -7,6 +7,7 @@ import (
"git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/tui/bubbles" "git.mlow.ca/mlow/lmcli/pkg/tui/bubbles"
"git.mlow.ca/mlow/lmcli/pkg/tui/model"
"git.mlow.ca/mlow/lmcli/pkg/tui/shared" "git.mlow.ca/mlow/lmcli/pkg/tui/shared"
"git.mlow.ca/mlow/lmcli/pkg/tui/styles" "git.mlow.ca/mlow/lmcli/pkg/tui/styles"
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util" tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
@ -16,40 +17,30 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type loadedConversation struct {
conv api.Conversation
lastReply api.Message
}
type ( type (
// sent when conversation list is loaded // sent when conversation list is loaded
msgConversationsLoaded ([]loadedConversation) msgConversationsLoaded ([]model.LoadedConversation)
// sent when a conversation is selected // sent when a conversation is selected
msgConversationSelected api.Conversation msgConversationSelected api.Conversation
// sent when a conversation is deleted // sent when a conversation is deleted
msgConversationDeleted struct{} msgConversationDeleted struct{}
) )
// Prompt payloads
type (
deleteConversationPayload api.Conversation
)
type Model struct { type Model struct {
shared.Shared shared.Shared
shared.Sections shared.Sections
conversations []loadedConversation App *model.AppModel
cursor int // index of the currently selected conversation cursor int
itemOffsets []int // keeps track of the viewport y offset of each rendered item
content viewport.Model itemOffsets []int // conversation y offsets
content viewport.Model
confirmPrompt bubbles.ConfirmPrompt confirmPrompt bubbles.ConfirmPrompt
} }
func Conversations(shared shared.Shared) Model { func Conversations(app *model.AppModel, shared shared.Shared) Model {
m := Model{ m := Model{
App: app,
Shared: shared, Shared: shared,
content: viewport.New(0, 0), content: viewport.New(0, 0),
} }
@ -67,16 +58,17 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "enter": case "enter":
if len(m.conversations) > 0 && m.cursor < len(m.conversations) { if len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
m.App.Conversation = &m.App.Conversations[m.cursor].Conv
m.App.Messages = []api.Message{}
return true, func() tea.Msg { return true, func() tea.Msg {
return msgConversationSelected(m.conversations[m.cursor].conv) return shared.MsgViewChange(shared.StateChat)
} }
} }
case "j", "down": case "j", "down":
if m.cursor < len(m.conversations)-1 { if m.cursor < len(m.App.Conversations)-1 {
m.cursor++ m.cursor++
if m.cursor == len(m.conversations)-1 { if m.cursor == len(m.App.Conversations)-1 {
// if last conversation, simply scroll to the bottom
m.content.GotoBottom() m.content.GotoBottom()
} else { } else {
// this hack positions the *next* conversatoin slightly // this hack positions the *next* conversatoin slightly
@ -86,7 +78,7 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
} }
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
} else { } else {
m.cursor = len(m.conversations) - 1 m.cursor = len(m.App.Conversations) - 1
m.content.GotoBottom() m.content.GotoBottom()
} }
return true, nil return true, nil
@ -107,14 +99,14 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
case "n": case "n":
// new conversation // new conversation
case "d": case "d":
if !m.confirmPrompt.Focused() && len(m.conversations) > 0 && m.cursor < len(m.conversations) { if !m.confirmPrompt.Focused() && len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
title := m.conversations[m.cursor].conv.Title title := m.App.Conversations[m.cursor].Conv.Title
if title == "" { if title == "" {
title = "(untitled)" title = "(untitled)"
} }
m.confirmPrompt = bubbles.NewConfirmPrompt( m.confirmPrompt = bubbles.NewConfirmPrompt(
fmt.Sprintf("Delete '%s'?", title), fmt.Sprintf("Delete '%s'?", title),
deleteConversationPayload(m.conversations[m.cursor].conv), m.App.Conversations[m.cursor].Conv,
) )
m.confirmPrompt.Style = lipgloss.NewStyle(). m.confirmPrompt.Style = lipgloss.NewStyle().
Bold(true). Bold(true).
@ -132,7 +124,7 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return nil return m.loadConversations()
} }
func (m *Model) HandleResize(width, height int) { func (m *Model) HandleResize(width, height int) {
@ -150,20 +142,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.HandleResize(msg.Width, msg.Height) m.HandleResize(msg.Width, msg.Height)
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
case msgConversationsLoaded: case msgConversationsLoaded:
m.conversations = msg m.App.Conversations = msg
m.cursor = max(0, min(len(m.conversations), m.cursor)) m.cursor = max(0, min(len(m.App.Conversations), m.cursor))
m.content.SetContent(m.renderConversationList()) m.content.SetContent(m.renderConversationList())
case msgConversationSelected:
m.Values.ConvShortname = msg.ShortName.String
cmds = append(cmds, func() tea.Msg {
return shared.MsgViewChange(shared.StateChat)
})
case bubbles.MsgConfirmPromptAnswered: case bubbles.MsgConfirmPromptAnswered:
m.confirmPrompt.Blur() m.confirmPrompt.Blur()
if msg.Value { if msg.Value {
switch payload := msg.Payload.(type) { conv, ok := msg.Payload.(api.Conversation)
case deleteConversationPayload: if ok {
cmds = append(cmds, m.deleteConversation(api.Conversation(payload))) cmds = append(cmds, m.deleteConversation(conv))
} }
} }
case msgConversationDeleted: case msgConversationDeleted:
@ -193,24 +180,17 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
func (m *Model) loadConversations() tea.Cmd { func (m *Model) loadConversations() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
messages, err := m.Ctx.Store.LatestConversationMessages() err, conversations := m.App.LoadConversations()
if err != nil { if err != nil {
return shared.MsgError(fmt.Errorf("Could not load conversations: %v", err)) return shared.MsgError(fmt.Errorf("Could not load conversations: %v", err))
} }
return msgConversationsLoaded(conversations)
loaded := make([]loadedConversation, len(messages))
for i, m := range messages {
loaded[i].lastReply = m
loaded[i].conv = *m.Conversation
}
return msgConversationsLoaded(loaded)
} }
} }
func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd { func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err := m.Ctx.Store.DeleteConversation(&conv) err := m.App.Ctx.Store.DeleteConversation(&conv)
if err != nil { if err != nil {
return shared.MsgError(fmt.Errorf("Could not delete conversation: %v", err)) return shared.MsgError(fmt.Errorf("Could not delete conversation: %v", err))
} }
@ -289,12 +269,12 @@ func (m *Model) renderConversationList() string {
sb strings.Builder sb strings.Builder
) )
m.itemOffsets = make([]int, len(m.conversations)) m.itemOffsets = make([]int, len(m.App.Conversations))
sb.WriteRune('\n') sb.WriteRune('\n')
currentOffset += 1 currentOffset += 1
for i, c := range m.conversations { for i, c := range m.App.Conversations {
lastReplyAge := now.Sub(c.lastReply.CreatedAt) lastReplyAge := now.Sub(c.LastReply.CreatedAt)
var category string var category string
for _, g := range categories { for _, g := range categories {
@ -314,14 +294,14 @@ func (m *Model) renderConversationList() string {
} }
tStyle := titleStyle.Copy() tStyle := titleStyle.Copy()
if c.conv.Title == "" { if c.Conv.Title == "" {
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)") tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
} }
if i == m.cursor { if i == m.cursor {
tStyle = tStyle.Inherit(selectedStyle) tStyle = tStyle.Inherit(selectedStyle)
} }
title := tStyle.Width(m.Width - 3).PaddingLeft(2).Render(c.conv.Title) title := tStyle.Width(m.Width - 3).PaddingLeft(2).Render(c.Conv.Title)
if i == m.cursor { if i == m.cursor {
title = ">" + title[1:] title = ">" + title[1:]
} }
@ -334,7 +314,7 @@ func (m *Model) renderConversationList() string {
)) ))
sb.WriteString(item) sb.WriteString(item)
currentOffset += tuiutil.Height(item) currentOffset += tuiutil.Height(item)
if i < len(m.conversations)-1 { if i < len(m.App.Conversations)-1 {
sb.WriteRune('\n') sb.WriteRune('\n')
} }
} }

View File

@ -138,7 +138,7 @@ func SetStructDefaults(data interface{}) bool {
// Get the "default" struct tag // Get the "default" struct tag
defaultTag, ok := v.Type().Field(i).Tag.Lookup("default") defaultTag, ok := v.Type().Field(i).Tag.Lookup("default")
if (!ok) { if !ok {
continue continue
} }