lmcli/pkg/tui/views/chat/update.go

303 lines
8.1 KiB
Go
Raw Normal View History

2024-06-02 16:40:46 -06:00
package chat
import (
"strings"
"time"
"git.mlow.ca/mlow/lmcli/pkg/api"
2024-06-02 16:40:46 -06:00
"git.mlow.ca/mlow/lmcli/pkg/tui/shared"
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
"github.com/charmbracelet/bubbles/cursor"
tea "github.com/charmbracelet/bubbletea"
)
func (m *Model) setMessage(i int, msg api.Message) {
if i >= len(m.App.Messages) {
panic("i out of range")
2024-06-02 16:40:46 -06:00
}
m.App.Messages[i] = msg
m.messageCache[i] = m.renderMessage(i)
}
func (m *Model) addMessage(msg api.Message) {
m.App.Messages = append(m.App.Messages, msg)
m.messageCache = append(m.messageCache, m.renderMessage(len(m.App.Messages)-1))
2024-06-02 16:40:46 -06:00
}
func (m *Model) setMessageContents(i int, content string) {
if i >= len(m.App.Messages) {
panic("i out of range")
}
m.App.Messages[i].Content = content
m.messageCache[i] = m.renderMessage(i)
}
func (m *Model) rebuildMessageCache() {
m.messageCache = make([]string, len(m.App.Messages))
for i := range m.App.Messages {
m.messageCache[i] = m.renderMessage(i)
}
}
func (m *Model) updateContent() {
atBottom := m.content.AtBottom()
m.content.SetContent(m.conversationMessagesView())
if atBottom {
m.content.GotoBottom()
2024-06-02 16:40:46 -06:00
}
}
func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
inputHandled := false
2024-06-02 16:40:46 -06:00
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
cmd := m.handleInput(msg)
if cmd != nil {
inputHandled = true
cmds = append(cmds, cmd)
}
case tea.WindowSizeMsg:
m.Width, m.Height = msg.Width, msg.Height
m.content.Width = msg.Width
m.input.SetWidth(msg.Width - m.input.FocusedStyle.Base.GetHorizontalFrameSize())
if len(m.App.Messages) > 0 {
m.rebuildMessageCache()
m.updateContent()
}
2024-06-02 16:40:46 -06:00
case shared.MsgViewEnter:
// wake up spinners and cursors
cmds = append(cmds, cursor.Blink, m.spinner.Tick)
// Refresh view
2024-06-02 16:40:46 -06:00
m.rebuildMessageCache()
m.updateContent()
if m.App.Conversation != nil && m.App.Conversation.ID > 0 {
// (re)load conversation contents
cmds = append(cmds, m.loadConversationMessages())
}
2024-06-02 16:40:46 -06:00
case tuiutil.MsgTempfileEditorClosed:
contents := string(msg)
switch m.editorTarget {
case input:
m.input.SetValue(contents)
case selectedMessage:
toEdit := m.App.Messages[m.selectedMessage]
if toEdit.Content != contents {
toEdit.Content = contents
m.setMessage(m.selectedMessage, toEdit)
if m.persistence && toEdit.ID > 0 {
// create clone of message with its new contents
cmds = append(cmds, m.cloneMessage(toEdit, true))
2024-06-02 16:40:46 -06:00
}
}
}
case msgConversationLoaded:
m.App.Conversation = msg.conversation
m.App.RootMessages = msg.rootMessages
m.selectedMessage = -1
if len(m.App.RootMessages) > 0 {
cmds = append(cmds, m.loadConversationMessages())
}
2024-06-02 16:40:46 -06:00
case msgMessagesLoaded:
m.App.Messages = msg
if m.selectedMessage == -1 {
m.selectedMessage = len(msg) - 1
} else {
m.selectedMessage = min(m.selectedMessage, len(m.App.Messages))
}
2024-06-02 16:40:46 -06:00
m.rebuildMessageCache()
m.updateContent()
case msgChatResponseChunk:
cmds = append(cmds, m.waitForResponseChunk()) // wait for the next chunk
2024-06-02 16:40:46 -06:00
if msg.Content == "" {
2024-06-02 16:40:46 -06:00
break
}
last := len(m.App.Messages) - 1
if last >= 0 && m.App.Messages[last].Role.IsAssistant() {
2024-06-02 16:40:46 -06:00
// append chunk to existing message
m.setMessageContents(last, m.App.Messages[last].Content+msg.Content)
2024-06-02 16:40:46 -06:00
} else {
2024-06-20 23:52:59 -06:00
// use chunk in a new message
m.addMessage(api.Message{
Role: api.MessageRoleAssistant,
Content: msg.Content,
2024-06-02 16:40:46 -06:00
})
}
m.updateContent()
// show cursor and reset blink interval (simulate typing)
m.replyCursor.Blink = false
cmds = append(cmds, m.replyCursor.BlinkCmd())
m.tokenCount += msg.TokenCount
2024-06-02 16:40:46 -06:00
m.elapsed = time.Now().Sub(m.startTime)
case msgChatResponse:
m.state = idle
2024-06-02 16:40:46 -06:00
reply := (*api.Message)(msg)
2024-06-02 16:40:46 -06:00
reply.Content = strings.TrimSpace(reply.Content)
last := len(m.App.Messages) - 1
2024-06-02 16:40:46 -06:00
if last < 0 {
panic("Unexpected empty messages handling msgAssistantReply")
}
if m.App.Messages[last].Role.IsAssistant() {
// TODO: handle continuations gracefully - some models support them well, others fail horribly.
m.setMessage(last, *reply)
2024-06-02 16:40:46 -06:00
} else {
m.addMessage(*reply)
}
switch reply.Role {
case api.MessageRoleToolCall:
// TODO: user confirmation before execution
// m.state = waitingForConfirmation
cmds = append(cmds, m.executeToolCalls(reply.ToolCalls))
2024-06-02 16:40:46 -06:00
}
if m.persistence {
cmds = append(cmds, m.persistConversation())
2024-06-02 16:40:46 -06:00
}
if m.App.Conversation.Title == "" {
2024-06-02 16:40:46 -06:00
cmds = append(cmds, m.generateConversationTitle())
}
m.updateContent()
case msgChatResponseCanceled:
m.state = idle
2024-06-02 16:40:46 -06:00
m.updateContent()
case msgChatResponseError:
m.state = idle
m.ViewState.Err = error(msg)
m.updateContent()
case msgToolResults:
last := len(m.App.Messages) - 1
if last < 0 {
panic("Unexpected empty messages handling msgAssistantReply")
}
if m.App.Messages[last].Role != api.MessageRoleToolCall {
panic("Previous message not a tool call, unexpected")
}
m.addMessage(api.Message{
Role: api.MessageRoleToolResult,
ToolResults: api.ToolResults(msg),
})
if m.persistence {
cmds = append(cmds, m.persistConversation())
}
2024-06-02 16:40:46 -06:00
m.updateContent()
case msgConversationTitleGenerated:
2024-06-02 16:40:46 -06:00
title := string(msg)
m.App.Conversation.Title = title
2024-06-02 16:40:46 -06:00
if m.persistence {
cmds = append(cmds, m.updateConversationTitle(m.App.Conversation))
2024-06-02 16:40:46 -06:00
}
case cursor.BlinkMsg:
if m.state == pendingResponse {
// ensure we show the updated "wait for response" cursor blink state
last := len(m.App.Messages) - 1
2024-06-20 23:52:59 -06:00
m.messageCache[last] = m.renderMessage(last)
2024-06-02 16:40:46 -06:00
m.updateContent()
}
case msgConversationPersisted:
m.App.Conversation = msg.conversation
m.App.Messages = msg.messages
if msg.isNew {
m.App.RootMessages = []api.Message{m.App.Messages[0]}
}
m.rebuildMessageCache()
m.updateContent()
case msgMessageCloned:
if msg.Parent == nil {
m.App.Conversation = msg.Conversation
m.App.RootMessages = append(m.App.RootMessages, *msg)
}
cmds = append(cmds, m.loadConversationMessages())
case msgSelectedRootCycled, msgSelectedReplyCycled, msgMessageUpdated:
cmds = append(cmds, m.loadConversationMessages())
2024-06-02 16:40:46 -06:00
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
m.replyCursor, cmd = m.replyCursor.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
prevInputLineCnt := m.input.LineCount()
if !inputHandled {
m.input, cmd = m.input.Update(msg)
if cmd != nil {
inputHandled = true
cmds = append(cmds, cmd)
}
2024-06-02 16:40:46 -06:00
}
if !inputHandled {
2024-06-02 16:40:46 -06:00
m.content, cmd = m.content.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
// update views once window dimensions are known
if m.Width > 0 {
m.Header = m.headerView()
m.Footer = m.footerView()
m.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer)
// calculate clamped input height to accomodate input text
// minimum 4 lines, maximum half of content area
newHeight := max(4, min((m.Height-fixedHeight-1)/2, m.input.LineCount()))
m.input.SetHeight(newHeight)
m.Input = m.input.View()
// remaining height towards content
m.content.Height = m.Height - fixedHeight - tuiutil.Height(m.Input)
m.Content = m.content.View()
}
// this is a pretty nasty hack to ensure the input area viewport doesn't
// scroll below its content, which can happen when the input viewport
// height has grown, or previously entered lines have been deleted
if prevInputLineCnt != m.input.LineCount() {
// dist is the distance we'd need to scroll up from the current cursor
// position to position the last input line at the bottom of the
// viewport. if negative, we're already scrolled above the bottom
dist := m.input.Line() - (m.input.LineCount() - m.input.Height())
if dist > 0 {
for i := 0; i < dist; i++ {
// move cursor up until content reaches the bottom of the viewport
m.input.CursorUp()
}
m.input, _ = m.input.Update(nil)
2024-06-02 16:40:46 -06:00
for i := 0; i < dist; i++ {
// move cursor back down to its previous position
m.input.CursorDown()
}
m.input, _ = m.input.Update(nil)
2024-06-02 16:40:46 -06:00
}
}
if len(cmds) > 0 {
return m, tea.Batch(cmds...)
}
return m, nil
2024-06-02 16:40:46 -06:00
}