tui: split model up into chat/conversations
This commit is contained in:
parent
020db40401
commit
e1970a315a
253
pkg/tui/chat.go
253
pkg/tui/chat.go
@ -5,9 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util"
|
cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util"
|
||||||
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/muesli/reflow/wordwrap"
|
"github.com/muesli/reflow/wordwrap"
|
||||||
@ -46,6 +50,86 @@ type (
|
|||||||
msgMessagesLoaded []models.Message
|
msgMessagesLoaded []models.Message
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type chatModel struct {
|
||||||
|
basemodel
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
// app state
|
||||||
|
conversation *models.Conversation
|
||||||
|
messages []models.Message
|
||||||
|
selectedMessage int
|
||||||
|
waitingForReply bool
|
||||||
|
editorTarget editorTarget
|
||||||
|
stopSignal chan struct{}
|
||||||
|
replyChan chan models.Message
|
||||||
|
replyChunkChan chan string
|
||||||
|
persistence bool // whether we will save new messages in the conversation
|
||||||
|
|
||||||
|
// ui state
|
||||||
|
focus focusState
|
||||||
|
wrap bool // whether message content is wrapped to viewport width
|
||||||
|
status string // a general status message
|
||||||
|
showToolResults bool // whether tool calls and results are shown
|
||||||
|
messageCache []string // cache of syntax highlighted and wrapped message content
|
||||||
|
messageOffsets []int
|
||||||
|
|
||||||
|
// ui elements
|
||||||
|
content viewport.Model
|
||||||
|
input textarea.Model
|
||||||
|
spinner spinner.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChatModel(tui *model) chatModel {
|
||||||
|
m := chatModel{
|
||||||
|
basemodel: basemodel{
|
||||||
|
opts: tui.opts,
|
||||||
|
ctx: tui.ctx,
|
||||||
|
views: tui.views,
|
||||||
|
},
|
||||||
|
|
||||||
|
conversation: &models.Conversation{},
|
||||||
|
persistence: true,
|
||||||
|
|
||||||
|
stopSignal: make(chan struct{}),
|
||||||
|
replyChan: make(chan models.Message),
|
||||||
|
replyChunkChan: make(chan string),
|
||||||
|
|
||||||
|
wrap: true,
|
||||||
|
selectedMessage: -1,
|
||||||
|
|
||||||
|
content: viewport.New(0, 0),
|
||||||
|
input: textarea.New(),
|
||||||
|
spinner: spinner.New(spinner.WithSpinner(
|
||||||
|
spinner.Spinner{
|
||||||
|
Frames: []string{
|
||||||
|
". ",
|
||||||
|
".. ",
|
||||||
|
"...",
|
||||||
|
".. ",
|
||||||
|
". ",
|
||||||
|
" ",
|
||||||
|
},
|
||||||
|
FPS: time.Second / 3,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
m.input.Focus()
|
||||||
|
m.input.MaxHeight = 0
|
||||||
|
m.input.CharLimit = 0
|
||||||
|
m.input.ShowLineNumbers = false
|
||||||
|
m.input.Placeholder = "Enter a message"
|
||||||
|
|
||||||
|
m.input.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||||
|
m.input.FocusedStyle.Base = inputFocusedStyle
|
||||||
|
m.input.BlurredStyle.Base = inputBlurredStyle
|
||||||
|
|
||||||
|
m.waitingForReply = false
|
||||||
|
m.status = "Press ctrl+s to send"
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// styles
|
// styles
|
||||||
var (
|
var (
|
||||||
headerStyle = lipgloss.NewStyle().
|
headerStyle = lipgloss.NewStyle().
|
||||||
@ -77,11 +161,25 @@ var (
|
|||||||
footerStyle = lipgloss.NewStyle()
|
footerStyle = lipgloss.NewStyle()
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *model) handleChatInput(msg tea.KeyMsg) tea.Cmd {
|
func (m *chatModel) handleInput(msg tea.KeyMsg) tea.Cmd {
|
||||||
|
switch m.focus {
|
||||||
|
case focusInput:
|
||||||
|
cmd := m.handleInputKey(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
case focusMessages:
|
||||||
|
cmd := m.handleMessagesKey(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.state = stateConversations
|
return func() tea.Msg {
|
||||||
return m.loadConversations()
|
return msgChangeState(stateConversations)
|
||||||
|
}
|
||||||
case "ctrl+p":
|
case "ctrl+p":
|
||||||
m.persistence = !m.persistence
|
m.persistence = !m.persistence
|
||||||
case "ctrl+t":
|
case "ctrl+t":
|
||||||
@ -92,20 +190,35 @@ func (m *model) handleChatInput(msg tea.KeyMsg) tea.Cmd {
|
|||||||
m.wrap = !m.wrap
|
m.wrap = !m.wrap
|
||||||
m.rebuildMessageCache()
|
m.rebuildMessageCache()
|
||||||
m.updateContent()
|
m.updateContent()
|
||||||
default:
|
|
||||||
switch m.focus {
|
|
||||||
case focusInput:
|
|
||||||
return m.handleChatInputKey(msg)
|
|
||||||
case focusMessages:
|
|
||||||
return m.handleChatMessagesKey(msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) handleChatUpdate(msg tea.Msg) []tea.Cmd {
|
func (m chatModel) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
textarea.Blink,
|
||||||
|
m.spinner.Tick,
|
||||||
|
m.waitForChunk(),
|
||||||
|
m.waitForReply(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case msgChangeState:
|
||||||
|
cmds = append(cmds, m.Init())
|
||||||
|
|
||||||
|
if m.opts.convShortname != "" && m.conversation.ShortName.String != m.opts.convShortname {
|
||||||
|
cmds = append(cmds, m.loadConversation(m.opts.convShortname))
|
||||||
|
}
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.content.Width = msg.Width
|
||||||
|
m.input.SetWidth(msg.Width - m.input.FocusedStyle.Base.GetHorizontalBorderSize())
|
||||||
|
m.rebuildMessageCache()
|
||||||
|
m.updateContent()
|
||||||
case msgTempfileEditorClosed:
|
case msgTempfileEditorClosed:
|
||||||
contents := string(msg)
|
contents := string(msg)
|
||||||
switch m.editorTarget {
|
switch m.editorTarget {
|
||||||
@ -225,7 +338,7 @@ func (m *model) handleChatUpdate(msg tea.Msg) []tea.Cmd {
|
|||||||
if m.width > 0 {
|
if m.width > 0 {
|
||||||
m.views.header = m.headerView()
|
m.views.header = m.headerView()
|
||||||
m.views.footer = m.footerView()
|
m.views.footer = m.footerView()
|
||||||
m.views.error = m.errorView()
|
m.views.error = errorBanner(m.err, m.width)
|
||||||
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
|
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
|
||||||
|
|
||||||
// calculate clamped input height to accomodate input text
|
// calculate clamped input height to accomodate input text
|
||||||
@ -259,10 +372,10 @@ func (m *model) handleChatUpdate(msg tea.Msg) []tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmds
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) handleChatMessagesKey(msg tea.KeyMsg) tea.Cmd {
|
func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) tea.Cmd {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "tab":
|
case "tab":
|
||||||
m.focus = focusInput
|
m.focus = focusInput
|
||||||
@ -301,19 +414,22 @@ func (m *model) handleChatMessagesKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) handleChatInputKey(msg tea.KeyMsg) tea.Cmd {
|
func (m *chatModel) handleInputKey(msg tea.KeyMsg) tea.Cmd {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.focus = focusMessages
|
return func() tea.Msg {
|
||||||
if len(m.messages) > 0 {
|
return msgChangeState(stateConversations)
|
||||||
if m.selectedMessage < 0 || m.selectedMessage >= len(m.messages) {
|
|
||||||
m.selectedMessage = len(m.messages) - 1
|
|
||||||
}
|
}
|
||||||
offset := m.messageOffsets[m.selectedMessage]
|
//m.focus = focusMessages
|
||||||
scrollIntoView(&m.content, offset, 0.1)
|
//if len(m.messages) > 0 {
|
||||||
}
|
// if m.selectedMessage < 0 || m.selectedMessage >= len(m.messages) {
|
||||||
m.updateContent()
|
// m.selectedMessage = len(m.messages) - 1
|
||||||
m.input.Blur()
|
// }
|
||||||
|
// offset := m.messageOffsets[m.selectedMessage]
|
||||||
|
// scrollIntoView(&m.content, offset, 0.1)
|
||||||
|
//}
|
||||||
|
//m.updateContent()
|
||||||
|
//m.input.Blur()
|
||||||
case "ctrl+s":
|
case "ctrl+s":
|
||||||
userInput := strings.TrimSpace(m.input.Value())
|
userInput := strings.TrimSpace(m.input.Value())
|
||||||
if strings.TrimSpace(userInput) == "" {
|
if strings.TrimSpace(userInput) == "" {
|
||||||
@ -365,7 +481,7 @@ func (m *model) handleChatInputKey(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) renderMessageHeading(i int, message *models.Message) string {
|
func (m *chatModel) renderMessageHeading(i int, message *models.Message) string {
|
||||||
icon := ""
|
icon := ""
|
||||||
friendly := message.Role.FriendlyRole()
|
friendly := message.Role.FriendlyRole()
|
||||||
style := lipgloss.NewStyle().Faint(true).Bold(true)
|
style := lipgloss.NewStyle().Faint(true).Bold(true)
|
||||||
@ -403,7 +519,7 @@ func (m *model) renderMessageHeading(i int, message *models.Message) string {
|
|||||||
return messageHeadingStyle.Render(prefix + user + suffix)
|
return messageHeadingStyle.Render(prefix + user + suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) renderMessage(msg *models.Message) string {
|
func (m *chatModel) renderMessage(msg *models.Message) string {
|
||||||
sb := &strings.Builder{}
|
sb := &strings.Builder{}
|
||||||
sb.Grow(len(msg.Content) * 2)
|
sb.Grow(len(msg.Content) * 2)
|
||||||
if msg.Content != "" {
|
if msg.Content != "" {
|
||||||
@ -479,7 +595,7 @@ func (m *model) renderMessage(msg *models.Message) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// render the conversation into a string
|
// render the conversation into a string
|
||||||
func (m *model) conversationMessagesView() string {
|
func (m *chatModel) conversationMessagesView() string {
|
||||||
sb := strings.Builder{}
|
sb := strings.Builder{}
|
||||||
|
|
||||||
m.messageOffsets = make([]int, len(m.messages))
|
m.messageOffsets = make([]int, len(m.messages))
|
||||||
@ -512,12 +628,68 @@ func (m *model) conversationMessagesView() string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) setMessages(messages []models.Message) {
|
func (m *chatModel) headerView() string {
|
||||||
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
var title string
|
||||||
|
if m.conversation != nil && m.conversation.Title != "" {
|
||||||
|
title = m.conversation.Title
|
||||||
|
} else {
|
||||||
|
title = "Untitled"
|
||||||
|
}
|
||||||
|
title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...")
|
||||||
|
header := titleStyle.Render(title)
|
||||||
|
return headerStyle.Width(m.width).Render(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chatModel) footerView() string {
|
||||||
|
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
|
||||||
|
segmentSeparator := "|"
|
||||||
|
|
||||||
|
savingStyle := segmentStyle.Copy().Bold(true)
|
||||||
|
saving := ""
|
||||||
|
if m.persistence {
|
||||||
|
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
|
||||||
|
} else {
|
||||||
|
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := m.status
|
||||||
|
if m.waitingForReply {
|
||||||
|
status += m.spinner.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
leftSegments := []string{
|
||||||
|
saving,
|
||||||
|
segmentStyle.Render(status),
|
||||||
|
}
|
||||||
|
rightSegments := []string{
|
||||||
|
segmentStyle.Render(fmt.Sprintf("Model: %s", *m.ctx.Config.Defaults.Model)),
|
||||||
|
}
|
||||||
|
|
||||||
|
left := strings.Join(leftSegments, segmentSeparator)
|
||||||
|
right := strings.Join(rightSegments, segmentSeparator)
|
||||||
|
|
||||||
|
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
|
||||||
|
remaining := m.width - totalWidth
|
||||||
|
|
||||||
|
var padding string
|
||||||
|
if remaining > 0 {
|
||||||
|
padding = strings.Repeat(" ", remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := left + padding + right
|
||||||
|
if remaining < 0 {
|
||||||
|
footer = truncateToCellWidth(footer, m.width, "...")
|
||||||
|
}
|
||||||
|
return footerStyle.Width(m.width).Render(footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chatModel) setMessages(messages []models.Message) {
|
||||||
m.messages = messages
|
m.messages = messages
|
||||||
m.rebuildMessageCache()
|
m.rebuildMessageCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) setMessage(i int, msg models.Message) {
|
func (m *chatModel) setMessage(i int, msg models.Message) {
|
||||||
if i >= len(m.messages) {
|
if i >= len(m.messages) {
|
||||||
panic("i out of range")
|
panic("i out of range")
|
||||||
}
|
}
|
||||||
@ -525,12 +697,12 @@ func (m *model) setMessage(i int, msg models.Message) {
|
|||||||
m.messageCache[i] = m.renderMessage(&msg)
|
m.messageCache[i] = m.renderMessage(&msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) addMessage(msg models.Message) {
|
func (m *chatModel) addMessage(msg models.Message) {
|
||||||
m.messages = append(m.messages, msg)
|
m.messages = append(m.messages, msg)
|
||||||
m.messageCache = append(m.messageCache, m.renderMessage(&msg))
|
m.messageCache = append(m.messageCache, m.renderMessage(&msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) setMessageContents(i int, content string) {
|
func (m *chatModel) setMessageContents(i int, content string) {
|
||||||
if i >= len(m.messages) {
|
if i >= len(m.messages) {
|
||||||
panic("i out of range")
|
panic("i out of range")
|
||||||
}
|
}
|
||||||
@ -538,14 +710,14 @@ func (m *model) setMessageContents(i int, content string) {
|
|||||||
m.messageCache[i] = m.renderMessage(&m.messages[i])
|
m.messageCache[i] = m.renderMessage(&m.messages[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) rebuildMessageCache() {
|
func (m *chatModel) rebuildMessageCache() {
|
||||||
m.messageCache = make([]string, len(m.messages))
|
m.messageCache = make([]string, len(m.messages))
|
||||||
for i, msg := range m.messages {
|
for i, msg := range m.messages {
|
||||||
m.messageCache[i] = m.renderMessage(&msg)
|
m.messageCache[i] = m.renderMessage(&msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) updateContent() {
|
func (m *chatModel) updateContent() {
|
||||||
atBottom := m.content.AtBottom()
|
atBottom := m.content.AtBottom()
|
||||||
m.content.SetContent(m.conversationMessagesView())
|
m.content.SetContent(m.conversationMessagesView())
|
||||||
if atBottom {
|
if atBottom {
|
||||||
@ -554,7 +726,7 @@ func (m *model) updateContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) loadConversation(shortname string) tea.Cmd {
|
func (m *chatModel) loadConversation(shortname string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if shortname == "" {
|
if shortname == "" {
|
||||||
return nil
|
return nil
|
||||||
@ -570,7 +742,7 @@ func (m *model) loadConversation(shortname string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) loadMessages(c *models.Conversation) tea.Cmd {
|
func (m *chatModel) loadMessages(c *models.Conversation) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
messages, err := m.ctx.Store.Messages(c)
|
messages, err := m.ctx.Store.Messages(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -580,7 +752,7 @@ func (m *model) loadMessages(c *models.Conversation) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) persistConversation() tea.Cmd {
|
func (m *chatModel) persistConversation() tea.Cmd {
|
||||||
existingMessages, err := m.ctx.Store.Messages(m.conversation)
|
existingMessages, err := m.ctx.Store.Messages(m.conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapError(fmt.Errorf("Could not retrieve existing conversation messages while trying to save: %v", err))
|
return wrapError(fmt.Errorf("Could not retrieve existing conversation messages while trying to save: %v", err))
|
||||||
@ -633,7 +805,7 @@ func (m *model) persistConversation() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) generateConversationTitle() tea.Cmd {
|
func (m *chatModel) generateConversationTitle() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
title, err := cmdutil.GenerateTitle(m.ctx, m.conversation)
|
title, err := cmdutil.GenerateTitle(m.ctx, m.conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -643,20 +815,19 @@ func (m *model) generateConversationTitle() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) waitForReply() tea.Cmd {
|
func (m *chatModel) waitForReply() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return msgAssistantReply(<-m.replyChan)
|
return msgAssistantReply(<-m.replyChan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) waitForChunk() tea.Cmd {
|
func (m *chatModel) waitForChunk() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return msgResponseChunk(<-m.replyChunkChan)
|
return msgResponseChunk(<-m.replyChunkChan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *chatModel) promptLLM() tea.Cmd {
|
||||||
func (m *model) promptLLM() tea.Cmd {
|
|
||||||
m.waitingForReply = true
|
m.waitingForReply = true
|
||||||
m.status = "Press ctrl+c to cancel"
|
m.status = "Press ctrl+c to cancel"
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
||||||
"git.mlow.ca/mlow/lmcli/pkg/util"
|
"git.mlow.ca/mlow/lmcli/pkg/util"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@ -17,12 +18,36 @@ type (
|
|||||||
msgConversationsLoaded []models.Conversation
|
msgConversationsLoaded []models.Conversation
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *model) handleConversationsInput(msg tea.KeyMsg) tea.Cmd {
|
type conversationsModel struct {
|
||||||
|
basemodel
|
||||||
|
|
||||||
|
conversations []models.Conversation
|
||||||
|
lastReplies []models.Message
|
||||||
|
|
||||||
|
content viewport.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConversationsModel(tui *model) conversationsModel {
|
||||||
|
m := conversationsModel{
|
||||||
|
basemodel: basemodel{
|
||||||
|
opts: tui.opts,
|
||||||
|
ctx: tui.ctx,
|
||||||
|
views: tui.views,
|
||||||
|
width: tui.width,
|
||||||
|
height: tui.height,
|
||||||
|
},
|
||||||
|
content: viewport.New(0, 0),
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *conversationsModel) handleInput(msg tea.KeyMsg) tea.Cmd {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "enter":
|
case "enter":
|
||||||
m.state = stateChat
|
// how to notify chats model
|
||||||
m.updateContent()
|
return func() tea.Msg {
|
||||||
// load selected conversation and switch state
|
return msgChangeState(stateChat)
|
||||||
|
}
|
||||||
case "n":
|
case "n":
|
||||||
// new conversation
|
// new conversation
|
||||||
case "d":
|
case "d":
|
||||||
@ -37,9 +62,17 @@ func (m *model) handleConversationsInput(msg tea.KeyMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) handleConversationsUpdate(msg tea.Msg) []tea.Cmd {
|
func (m conversationsModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m conversationsModel) Update(msg tea.Msg) (conversationsModel, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case msgChangeState:
|
||||||
|
cmds = append(cmds, m.loadConversations())
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.content.Width = msg.Width
|
||||||
case msgConversationsLoaded:
|
case msgConversationsLoaded:
|
||||||
m.conversations = msg
|
m.conversations = msg
|
||||||
m.content.SetContent(m.renderConversationList())
|
m.content.SetContent(m.renderConversationList())
|
||||||
@ -53,17 +86,16 @@ func (m *model) handleConversationsUpdate(msg tea.Msg) []tea.Cmd {
|
|||||||
|
|
||||||
if m.width > 0 {
|
if m.width > 0 {
|
||||||
m.views.header = m.headerView()
|
m.views.header = m.headerView()
|
||||||
m.views.footer = m.footerView()
|
m.views.footer = "" // TODO: show /something/
|
||||||
m.views.error = m.errorView()
|
m.views.error = errorBanner(m.err, m.width)
|
||||||
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
|
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
|
||||||
|
|
||||||
m.content.Height = m.height - fixedHeight
|
m.content.Height = m.height - fixedHeight
|
||||||
m.views.content = m.content.View()
|
m.views.content = m.content.View()
|
||||||
}
|
}
|
||||||
return cmds
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) loadConversations() tea.Cmd {
|
func (m *conversationsModel) loadConversations() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
c, err := m.ctx.Store.Conversations()
|
c, err := m.ctx.Store.Conversations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -73,17 +105,23 @@ func (m *model) loadConversations() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) loadConversationLastestReplies(conversations []models.Conversation) tea.Cmd {
|
//func (m *conversationsModel) loadConversationLastestReplies(conversations []models.Conversation) tea.Cmd {
|
||||||
return func() tea.Msg {
|
// return func() tea.Msg {
|
||||||
//lastMessage, err := m.ctx.Store.LastMessage(&conversation)
|
// //lastMessage, err := m.ctx.Store.LastMessage(&conversation)
|
||||||
return nil
|
// return nil
|
||||||
}
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
//func (m *conversationsModel) setConversations(conversations []models.Conversation) {
|
||||||
|
//}
|
||||||
|
|
||||||
|
func (m *conversationsModel) headerView() string {
|
||||||
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
header := titleStyle.Render("Conversations")
|
||||||
|
return headerStyle.Width(m.width).Render(header)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) setConversations(conversations []models.Conversation) {
|
func (m *conversationsModel) renderConversationList() string {
|
||||||
}
|
|
||||||
|
|
||||||
func (m *model) renderConversationList() string {
|
|
||||||
sb := &strings.Builder{}
|
sb := &strings.Builder{}
|
||||||
type AgeGroup struct {
|
type AgeGroup struct {
|
||||||
name string
|
name string
|
||||||
|
231
pkg/tui/tui.go
231
pkg/tui/tui.go
@ -9,19 +9,13 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
|
"git.mlow.ca/mlow/lmcli/pkg/lmcli"
|
||||||
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
|
||||||
"github.com/charmbracelet/bubbles/textarea"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type appState int
|
type state int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateChat = iota
|
stateChat = iota
|
||||||
@ -32,7 +26,7 @@ const (
|
|||||||
|
|
||||||
// this struct holds the final rendered content of various UI components, and
|
// this struct holds the final rendered content of various UI components, and
|
||||||
// gets populated in the application's Update() method. View() simply composes
|
// gets populated in the application's Update() method. View() simply composes
|
||||||
// these elements into the final view
|
// these elements into the final output
|
||||||
type views struct {
|
type views struct {
|
||||||
header string
|
header string
|
||||||
content string
|
content string
|
||||||
@ -42,135 +36,72 @@ type views struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
// send to change the current app state
|
||||||
|
msgChangeState state
|
||||||
// sent when an error occurs
|
// sent when an error occurs
|
||||||
msgError error
|
msgError error
|
||||||
)
|
)
|
||||||
|
|
||||||
type model struct {
|
type Options struct {
|
||||||
width int
|
|
||||||
height int
|
|
||||||
|
|
||||||
ctx *lmcli.Context
|
|
||||||
convShortname string
|
convShortname string
|
||||||
|
|
||||||
// application state
|
|
||||||
state appState
|
|
||||||
conversations []models.Conversation
|
|
||||||
lastReplies []models.Message
|
|
||||||
conversation *models.Conversation
|
|
||||||
messages []models.Message
|
|
||||||
selectedMessage int
|
|
||||||
waitingForReply bool
|
|
||||||
editorTarget editorTarget
|
|
||||||
stopSignal chan interface{}
|
|
||||||
replyChan chan models.Message
|
|
||||||
replyChunkChan chan string
|
|
||||||
persistence bool // whether we will save new messages in the conversation
|
|
||||||
err error
|
|
||||||
|
|
||||||
// ui state
|
|
||||||
focus focusState
|
|
||||||
wrap bool // whether message content is wrapped to viewport width
|
|
||||||
status string // a general status message
|
|
||||||
showToolResults bool // whether tool calls and results are shown
|
|
||||||
messageCache []string // cache of syntax highlighted and wrapped message content
|
|
||||||
messageOffsets []int
|
|
||||||
|
|
||||||
// ui elements
|
|
||||||
content viewport.Model
|
|
||||||
input textarea.Model
|
|
||||||
spinner spinner.Model
|
|
||||||
views *views
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialModel(ctx *lmcli.Context, convShortname string) model {
|
type basemodel struct {
|
||||||
|
opts *Options
|
||||||
|
ctx *lmcli.Context
|
||||||
|
views *views
|
||||||
|
err error
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
basemodel
|
||||||
|
|
||||||
|
state state
|
||||||
|
chat chatModel
|
||||||
|
conversations conversationsModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialModel(ctx *lmcli.Context, opts Options) model {
|
||||||
m := model{
|
m := model{
|
||||||
|
basemodel: basemodel{
|
||||||
|
opts: &opts,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
convShortname: convShortname,
|
|
||||||
conversation: &models.Conversation{},
|
|
||||||
persistence: true,
|
|
||||||
|
|
||||||
stopSignal: make(chan interface{}),
|
|
||||||
replyChan: make(chan models.Message),
|
|
||||||
replyChunkChan: make(chan string),
|
|
||||||
|
|
||||||
wrap: true,
|
|
||||||
selectedMessage: -1,
|
|
||||||
|
|
||||||
views: &views{},
|
views: &views{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
m.state = stateChat
|
m.state = stateChat
|
||||||
|
m.chat = newChatModel(&m)
|
||||||
m.content = viewport.New(0, 0)
|
m.conversations = newConversationsModel(&m)
|
||||||
|
|
||||||
m.input = textarea.New()
|
|
||||||
m.input.MaxHeight = 0
|
|
||||||
m.input.CharLimit = 0
|
|
||||||
m.input.Placeholder = "Enter a message"
|
|
||||||
|
|
||||||
m.input.Focus()
|
|
||||||
m.input.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
|
||||||
m.input.FocusedStyle.Base = inputFocusedStyle
|
|
||||||
m.input.BlurredStyle.Base = inputBlurredStyle
|
|
||||||
m.input.ShowLineNumbers = false
|
|
||||||
|
|
||||||
m.spinner = spinner.New(spinner.WithSpinner(
|
|
||||||
spinner.Spinner{
|
|
||||||
Frames: []string{
|
|
||||||
". ",
|
|
||||||
".. ",
|
|
||||||
"...",
|
|
||||||
".. ",
|
|
||||||
". ",
|
|
||||||
" ",
|
|
||||||
},
|
|
||||||
FPS: time.Second / 3,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
m.waitingForReply = false
|
|
||||||
m.status = "Press ctrl+s to send"
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
cmds := []tea.Cmd{
|
return func() tea.Msg {
|
||||||
textarea.Blink,
|
return msgChangeState(m.state)
|
||||||
m.spinner.Tick,
|
|
||||||
m.waitForChunk(),
|
|
||||||
m.waitForReply(),
|
|
||||||
}
|
}
|
||||||
switch m.state {
|
|
||||||
case stateChat:
|
|
||||||
if m.convShortname != "" {
|
|
||||||
cmds = append(cmds, m.loadConversation(m.convShortname))
|
|
||||||
}
|
|
||||||
case stateConversations:
|
|
||||||
cmds = append(cmds, m.loadConversations())
|
|
||||||
}
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
if m.waitingForReply {
|
if m.chat.waitingForReply {
|
||||||
m.stopSignal <- ""
|
m.chat.stopSignal <- struct{}{}
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
return tea.Quit
|
return tea.Quit
|
||||||
}
|
}
|
||||||
case "q":
|
case "q":
|
||||||
if m.focus != focusInput {
|
if m.chat.focus != focusInput {
|
||||||
return tea.Quit
|
return tea.Quit
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateChat:
|
case stateChat:
|
||||||
return m.handleChatInput(msg)
|
return m.chat.handleInput(msg)
|
||||||
case stateConversations:
|
case stateConversations:
|
||||||
return m.handleConversationsInput(msg)
|
return m.conversations.handleInput(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -185,22 +116,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
case msgChangeState:
|
||||||
|
m.state = state(msg)
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.content.Width = msg.Width
|
w, h := msg.Width, msg.Height
|
||||||
m.input.SetWidth(msg.Width - m.input.FocusedStyle.Base.GetHorizontalBorderSize())
|
m.width, m.height = w, h
|
||||||
m.rebuildMessageCache()
|
m.chat.width, m.chat.height = w, h
|
||||||
m.updateContent()
|
m.conversations.width, m.conversations.height = w, h
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
case msgError:
|
case msgError:
|
||||||
m.err = msg
|
m.err = msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateConversations:
|
case stateConversations:
|
||||||
cmds = append(cmds, m.handleConversationsUpdate(msg)...)
|
m.conversations, cmd = m.conversations.Update(msg)
|
||||||
case stateChat:
|
case stateChat:
|
||||||
cmds = append(cmds, m.handleChatUpdate(msg)...)
|
m.chat, cmd = m.chat.Update(msg)
|
||||||
|
}
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
@ -240,78 +175,16 @@ func (m model) View() string {
|
|||||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) headerView() string {
|
func errorBanner(err error, width int) string {
|
||||||
titleStyle := lipgloss.NewStyle().Bold(true)
|
if err == nil {
|
||||||
var header string
|
|
||||||
switch m.state {
|
|
||||||
case stateChat:
|
|
||||||
var title string
|
|
||||||
if m.conversation != nil && m.conversation.Title != "" {
|
|
||||||
title = m.conversation.Title
|
|
||||||
} else {
|
|
||||||
title = "Untitled"
|
|
||||||
}
|
|
||||||
title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...")
|
|
||||||
header = titleStyle.Render(title)
|
|
||||||
case stateConversations:
|
|
||||||
header = titleStyle.Render("Conversations")
|
|
||||||
}
|
|
||||||
return headerStyle.Width(m.width).Render(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *model) errorView() string {
|
|
||||||
if m.err == nil {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(m.width).
|
Width(width).
|
||||||
AlignHorizontal(lipgloss.Center).
|
AlignHorizontal(lipgloss.Center).
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(lipgloss.Color("1")).
|
Foreground(lipgloss.Color("1")).
|
||||||
Render(fmt.Sprintf("%s", m.err))
|
Render(fmt.Sprintf("%s", err))
|
||||||
}
|
|
||||||
|
|
||||||
func (m *model) footerView() string {
|
|
||||||
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
|
|
||||||
segmentSeparator := "|"
|
|
||||||
|
|
||||||
savingStyle := segmentStyle.Copy().Bold(true)
|
|
||||||
saving := ""
|
|
||||||
if m.persistence {
|
|
||||||
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
|
|
||||||
} else {
|
|
||||||
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
|
|
||||||
}
|
|
||||||
|
|
||||||
status := m.status
|
|
||||||
if m.waitingForReply {
|
|
||||||
status += m.spinner.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
leftSegments := []string{
|
|
||||||
saving,
|
|
||||||
segmentStyle.Render(status),
|
|
||||||
}
|
|
||||||
rightSegments := []string{
|
|
||||||
segmentStyle.Render(fmt.Sprintf("Model: %s", *m.ctx.Config.Defaults.Model)),
|
|
||||||
}
|
|
||||||
|
|
||||||
left := strings.Join(leftSegments, segmentSeparator)
|
|
||||||
right := strings.Join(rightSegments, segmentSeparator)
|
|
||||||
|
|
||||||
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
|
|
||||||
remaining := m.width - totalWidth
|
|
||||||
|
|
||||||
var padding string
|
|
||||||
if remaining > 0 {
|
|
||||||
padding = strings.Repeat(" ", remaining)
|
|
||||||
}
|
|
||||||
|
|
||||||
footer := left + padding + right
|
|
||||||
if remaining < 0 {
|
|
||||||
footer = truncateToCellWidth(footer, m.width, "...")
|
|
||||||
}
|
|
||||||
return footerStyle.Width(m.width).Render(footer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapError(err error) tea.Cmd {
|
func wrapError(err error) tea.Cmd {
|
||||||
@ -321,7 +194,7 @@ func wrapError(err error) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Launch(ctx *lmcli.Context, convShortname string) error {
|
func Launch(ctx *lmcli.Context, convShortname string) error {
|
||||||
p := tea.NewProgram(initialModel(ctx, convShortname), tea.WithAltScreen())
|
p := tea.NewProgram(initialModel(ctx, Options{convShortname}), tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
return fmt.Errorf("Error running program: %v", err)
|
return fmt.Errorf("Error running program: %v", err)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user