Matt Low
0384c7cb66
This refactor splits out all conversation concerns into a new `conversation` package. There is now a split between `conversation` and `api`s representation of `Message`, the latter storing the minimum information required for interaction with LLM providers. There is necessary conversation between the two when making LLM calls.
313 lines
8.1 KiB
Go
313 lines
8.1 KiB
Go
package conversations
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/conversation"
|
|
"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/styles"
|
|
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
|
|
"git.mlow.ca/mlow/lmcli/pkg/util"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type (
|
|
// sent when conversation list is loaded
|
|
msgConversationsLoaded ([]model.LoadedConversation)
|
|
// sent when a conversation is selected
|
|
msgConversationSelected conversation.Conversation
|
|
// sent when a conversation is deleted
|
|
msgConversationDeleted struct{}
|
|
)
|
|
|
|
type Model struct {
|
|
App *model.AppModel
|
|
width int
|
|
height int
|
|
|
|
cursor int
|
|
itemOffsets []int // conversation y offsets
|
|
|
|
content viewport.Model
|
|
confirmPrompt bubbles.ConfirmPrompt
|
|
}
|
|
|
|
func Conversations(app *model.AppModel) *Model {
|
|
viewport.New(0, 0)
|
|
m := Model{
|
|
App: app,
|
|
content: viewport.New(0, 0),
|
|
}
|
|
return &m
|
|
}
|
|
|
|
func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd {
|
|
if m.confirmPrompt.Focused() {
|
|
var cmd tea.Cmd
|
|
m.confirmPrompt, cmd = m.confirmPrompt.Update(msg)
|
|
if cmd != nil {
|
|
return cmd
|
|
}
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "enter":
|
|
if len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
|
|
m.App.ClearConversation()
|
|
m.App.Conversation = &m.App.Conversations[m.cursor].Conv
|
|
return func() tea.Msg {
|
|
return shared.MsgViewChange(shared.ViewChat)
|
|
}
|
|
}
|
|
case "j", "down":
|
|
if m.cursor < len(m.App.Conversations)-1 {
|
|
m.cursor++
|
|
if m.cursor == len(m.App.Conversations)-1 {
|
|
m.content.GotoBottom()
|
|
} else {
|
|
// this hack positions the *next* conversatoin slightly
|
|
// *off* the screen, ensuring the entire m.cursor is shown,
|
|
// even if its height may not be constant due to wrapping.
|
|
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor+1], -1)
|
|
}
|
|
m.content.SetContent(m.renderConversationList())
|
|
} else {
|
|
m.cursor = len(m.App.Conversations) - 1
|
|
m.content.GotoBottom()
|
|
}
|
|
return shared.KeyHandled(msg)
|
|
case "k", "up":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
if m.cursor == 0 {
|
|
m.content.GotoTop()
|
|
} else {
|
|
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor], 1)
|
|
}
|
|
m.content.SetContent(m.renderConversationList())
|
|
} else {
|
|
m.cursor = 0
|
|
m.content.GotoTop()
|
|
}
|
|
return shared.KeyHandled(msg)
|
|
case "n":
|
|
m.App.NewConversation()
|
|
return shared.ChangeView(shared.ViewChat)
|
|
case "d":
|
|
if !m.confirmPrompt.Focused() && len(m.App.Conversations) > 0 && m.cursor < len(m.App.Conversations) {
|
|
title := m.App.Conversations[m.cursor].Conv.Title
|
|
if title == "" {
|
|
title = "(untitled)"
|
|
}
|
|
m.confirmPrompt = bubbles.NewConfirmPrompt(
|
|
fmt.Sprintf("Delete '%s'?", title),
|
|
m.App.Conversations[m.cursor].Conv,
|
|
)
|
|
m.confirmPrompt.Style = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("3"))
|
|
return shared.KeyHandled(msg)
|
|
}
|
|
case "c":
|
|
// copy/clone conversation
|
|
case "r":
|
|
// show prompt to rename conversation
|
|
case "shift+r":
|
|
// show prompt to generate name for conversation
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) {
|
|
isInput := false
|
|
inputHandled := false
|
|
|
|
var cmds []tea.Cmd
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
isInput = true
|
|
cmd := m.handleInput(msg)
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
inputHandled = true
|
|
}
|
|
case shared.MsgViewEnter:
|
|
cmds = append(cmds, m.loadConversations())
|
|
m.content.SetContent(m.renderConversationList())
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
m.content.SetContent(m.renderConversationList())
|
|
case msgConversationsLoaded:
|
|
m.App.Conversations = msg
|
|
m.cursor = max(0, min(len(m.App.Conversations), m.cursor))
|
|
m.content.SetContent(m.renderConversationList())
|
|
case bubbles.MsgConfirmPromptAnswered:
|
|
m.confirmPrompt.Blur()
|
|
if msg.Value {
|
|
conv, ok := msg.Payload.(conversation.Conversation)
|
|
if ok {
|
|
cmds = append(cmds, m.deleteConversation(conv))
|
|
}
|
|
}
|
|
case msgConversationDeleted:
|
|
cmds = append(cmds, m.loadConversations())
|
|
}
|
|
|
|
if !isInput || !inputHandled {
|
|
content, cmd := m.content.Update(msg)
|
|
m.content = content
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
|
|
if len(cmds) > 0 {
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) loadConversations() tea.Cmd {
|
|
return func() tea.Msg {
|
|
err, conversations := m.App.LoadConversations()
|
|
if err != nil {
|
|
return shared.AsMsgError(fmt.Errorf("Could not load conversations: %v", err))
|
|
}
|
|
return msgConversationsLoaded(conversations)
|
|
}
|
|
}
|
|
|
|
func (m *Model) deleteConversation(conv conversation.Conversation) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := m.App.Ctx.Conversations.DeleteConversation(&conv)
|
|
if err != nil {
|
|
return shared.AsMsgError(fmt.Errorf("Could not delete conversation: %v", err))
|
|
}
|
|
return msgConversationDeleted{}
|
|
}
|
|
}
|
|
|
|
func (m *Model) Header(width int) string {
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
header := titleStyle.Render("Conversations")
|
|
return styles.Header.Width(width).Render(header)
|
|
}
|
|
|
|
func (m *Model) Content(width int, height int) string {
|
|
m.content.Width, m.content.Height = width, height
|
|
return m.content.View()
|
|
}
|
|
|
|
func (m *Model) Footer(width int) string {
|
|
if m.confirmPrompt.Focused() {
|
|
return lipgloss.NewStyle().Width(width).Render(m.confirmPrompt.View())
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Model) renderConversationList() string {
|
|
now := time.Now()
|
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
dayOfWeek := int(now.Weekday())
|
|
categories := []struct {
|
|
name string
|
|
cutoff time.Duration
|
|
}{
|
|
{"Today", now.Sub(midnight)},
|
|
{"Yesterday", now.Sub(midnight.AddDate(0, 0, -1))},
|
|
{"This week", now.Sub(midnight.AddDate(0, 0, -dayOfWeek))},
|
|
{"Last week", now.Sub(midnight.AddDate(0, 0, -(dayOfWeek + 7)))},
|
|
{"This month", now.Sub(monthStart)},
|
|
{"Last month", now.Sub(monthStart.AddDate(0, -1, 0))},
|
|
{"2 Months ago", now.Sub(monthStart.AddDate(0, -2, 0))},
|
|
{"3 Months ago", now.Sub(monthStart.AddDate(0, -3, 0))},
|
|
{"4 Months ago", now.Sub(monthStart.AddDate(0, -4, 0))},
|
|
{"5 Months ago", now.Sub(monthStart.AddDate(0, -5, 0))},
|
|
{"6 Months ago", now.Sub(monthStart.AddDate(0, -6, 0))},
|
|
{"Older", now.Sub(time.Time{})},
|
|
}
|
|
|
|
categoryStyle := lipgloss.NewStyle().
|
|
MarginBottom(1).
|
|
Foreground(lipgloss.Color("12")).
|
|
PaddingLeft(1).
|
|
Bold(true)
|
|
|
|
itemStyle := lipgloss.NewStyle().
|
|
MarginBottom(1)
|
|
|
|
ageStyle := lipgloss.NewStyle().Faint(true).SetString()
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
untitledStyle := lipgloss.NewStyle().Faint(true).Italic(true)
|
|
selectedStyle := lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6"))
|
|
|
|
var (
|
|
currentOffset int
|
|
currentCategory string
|
|
sb strings.Builder
|
|
)
|
|
|
|
m.itemOffsets = make([]int, len(m.App.Conversations))
|
|
sb.WriteRune('\n')
|
|
currentOffset += 1
|
|
|
|
for i, c := range m.App.Conversations {
|
|
lastReplyAge := now.Sub(c.LastReply.CreatedAt)
|
|
|
|
var category string
|
|
for _, g := range categories {
|
|
if lastReplyAge < g.cutoff {
|
|
category = g.name
|
|
break
|
|
}
|
|
}
|
|
|
|
// print the category
|
|
if category != currentCategory {
|
|
currentCategory = category
|
|
heading := categoryStyle.Render(currentCategory)
|
|
sb.WriteString(heading)
|
|
currentOffset += tuiutil.Height(heading)
|
|
sb.WriteRune('\n')
|
|
}
|
|
|
|
tStyle := titleStyle
|
|
if c.Conv.Title == "" {
|
|
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
|
|
}
|
|
if i == m.cursor {
|
|
tStyle = tStyle.Inherit(selectedStyle)
|
|
}
|
|
|
|
title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.Conv.Title)
|
|
if i == m.cursor {
|
|
title = ">" + title[1:]
|
|
}
|
|
|
|
m.itemOffsets[i] = currentOffset
|
|
item := itemStyle.Render(fmt.Sprintf(
|
|
"%s\n %s",
|
|
title,
|
|
ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)),
|
|
))
|
|
sb.WriteString(item)
|
|
currentOffset += tuiutil.Height(item)
|
|
if i < len(m.App.Conversations)-1 {
|
|
sb.WriteRune('\n')
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|