package chat import ( "strings" "time" models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model" "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) HandleResize(width, height int) { m.Width, m.Height = width, height m.content.Width = width m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize()) if len(m.messages) > 0 { m.rebuildMessageCache() m.updateContent() } } func (m *Model) waitForResponse() tea.Cmd { return func() tea.Msg { return msgResponse(<-m.replyChan) } } func (m *Model) waitForResponseChunk() tea.Cmd { return func() tea.Msg { return msgResponseChunk(<-m.replyChunkChan) } } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.HandleResize(msg.Width, msg.Height) case shared.MsgViewEnter: // wake up spinners and cursors cmds = append(cmds, cursor.Blink, m.spinner.Tick) if m.State.Values.ConvShortname != "" { // (re)load conversation contents cmds = append(cmds, m.loadConversation(m.State.Values.ConvShortname)) if m.conversation.ShortName.String != m.State.Values.ConvShortname { // clear existing messages if we're loading a new conversation m.messages = []models.Message{} m.selectedMessage = 0 } } m.rebuildMessageCache() m.updateContent() case tuiutil.MsgTempfileEditorClosed: contents := string(msg) switch m.editorTarget { case input: m.input.SetValue(contents) case selectedMessage: toEdit := m.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)) } } } case msgConversationLoaded: m.conversation = msg.conversation m.rootMessages = msg.rootMessages m.selectedMessage = -1 if len(m.rootMessages) > 0 { cmds = append(cmds, m.loadConversationMessages()) } case msgMessagesLoaded: m.messages = msg if m.selectedMessage == -1 { m.selectedMessage = len(msg) - 1 } else { m.selectedMessage = min(m.selectedMessage, len(m.messages)) } m.rebuildMessageCache() m.updateContent() case msgResponseChunk: cmds = append(cmds, m.waitForResponseChunk()) // wait for the next chunk chunk := string(msg) if chunk == "" { break } last := len(m.messages) - 1 if last >= 0 && m.messages[last].Role.IsAssistant() { // append chunk to existing message m.setMessageContents(last, m.messages[last].Content+chunk) } else { // use chunk in new message m.addMessage(models.Message{ Role: models.MessageRoleAssistant, Content: chunk, }) } m.updateContent() // show cursor and reset blink interval (simulate typing) m.replyCursor.Blink = false cmds = append(cmds, m.replyCursor.BlinkCmd()) m.tokenCount++ m.elapsed = time.Now().Sub(m.startTime) case msgResponse: cmds = append(cmds, m.waitForResponse()) // wait for the next response reply := models.Message(msg) reply.Content = strings.TrimSpace(reply.Content) last := len(m.messages) - 1 if last < 0 { panic("Unexpected empty messages handling msgAssistantReply") } if reply.Role.IsAssistant() && m.messages[last].Role.IsAssistant() { // this was a continuation, so replace the previous message with the completed reply m.setMessage(last, reply) } else { m.addMessage(reply) } if m.persistence { cmds = append(cmds, m.persistConversation()) } if m.conversation.Title == "" { cmds = append(cmds, m.generateConversationTitle()) } m.updateContent() case msgResponseEnd: m.waitingForReply = false last := len(m.messages) - 1 if last < 0 { panic("Unexpected empty messages handling msgResponseEnd") } m.setMessageContents(last, strings.TrimSpace(m.messages[last].Content)) m.updateContent() m.status = "Press ctrl+s to send" case msgResponseError: m.waitingForReply = false m.status = "Press ctrl+s to send" m.State.Err = error(msg) m.updateContent() case msgConversationTitleGenerated: title := string(msg) m.conversation.Title = title if m.persistence { cmds = append(cmds, m.updateConversationTitle(m.conversation)) } case cursor.BlinkMsg: if m.waitingForReply { // ensure we show the updated "wait for response" cursor blink state m.updateContent() } case msgConversationPersisted: m.conversation = msg.conversation m.messages = msg.messages m.rebuildMessageCache() m.updateContent() case msgMessageCloned: if msg.Parent == nil { m.conversation = &msg.Conversation m.rootMessages = append(m.rootMessages, *msg) } cmds = append(cmds, m.loadConversationMessages()) case msgSelectedRootCycled, msgSelectedReplyCycled, msgMessageUpdated: cmds = append(cmds, m.loadConversationMessages()) } 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() inputCaptured := false m.input, cmd = m.input.Update(msg) if cmd != nil { inputCaptured = true cmds = append(cmds, cmd) } if !inputCaptured { 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) for i := 0; i < dist; i++ { // move cursor back down to its previous position m.input.CursorDown() } m.input, _ = m.input.Update(nil) } } return m, tea.Batch(cmds...) }