tui: split model up into chat/conversations

This commit is contained in:
Matt Low 2024-03-31 23:51:45 +00:00
parent 020db40401
commit e1970a315a
3 changed files with 324 additions and 242 deletions

View File

@ -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"

View File

@ -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

View File

@ -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)
} }