lmcli/pkg/tui/views/conversations/conversations.go

343 lines
8.8 KiB
Go
Raw Normal View History

package conversations
import (
"fmt"
"strings"
"time"
"git.mlow.ca/mlow/lmcli/pkg/api"
"git.mlow.ca/mlow/lmcli/pkg/tui/bubbles"
"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"
)
2024-04-01 15:26:45 -06:00
type loadedConversation struct {
conv api.Conversation
lastReply api.Message
2024-04-01 15:26:45 -06:00
}
type (
2024-04-01 15:26:45 -06:00
// sent when conversation list is loaded
msgConversationsLoaded ([]loadedConversation)
// sent when a conversation is selected
msgConversationSelected api.Conversation
// sent when a conversation is deleted
msgConversationDeleted struct{}
)
// Prompt payloads
type (
deleteConversationPayload api.Conversation
)
type Model struct {
2024-06-08 16:01:16 -06:00
shared.Shared
2024-05-30 01:04:55 -06:00
shared.Sections
2024-04-01 15:26:45 -06:00
conversations []loadedConversation
2024-04-02 00:53:29 -06:00
cursor int // index of the currently selected conversation
itemOffsets []int // keeps track of the viewport y offset of each rendered item
content viewport.Model
confirmPrompt bubbles.ConfirmPrompt
}
2024-06-08 16:01:16 -06:00
func Conversations(shared shared.Shared) Model {
m := Model{
2024-06-08 16:01:16 -06:00
Shared: shared,
content: viewport.New(0, 0),
}
return m
}
func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) {
if m.confirmPrompt.Focused() {
var cmd tea.Cmd
m.confirmPrompt, cmd = m.confirmPrompt.Update(msg)
if cmd != nil {
return true, cmd
}
}
switch msg.String() {
case "enter":
if len(m.conversations) > 0 && m.cursor < len(m.conversations) {
return true, func() tea.Msg {
2024-05-30 01:18:31 -06:00
return msgConversationSelected(m.conversations[m.cursor].conv)
}
}
case "j", "down":
if m.cursor < len(m.conversations)-1 {
m.cursor++
2024-04-02 00:53:29 -06:00
if m.cursor == len(m.conversations)-1 {
// if last conversation, simply scroll to the bottom
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)
2024-04-02 00:53:29 -06:00
}
m.content.SetContent(m.renderConversationList())
2024-04-02 00:53:29 -06:00
} else {
m.cursor = len(m.conversations) - 1
m.content.GotoBottom()
}
return true, nil
case "k", "up":
if m.cursor > 0 {
m.cursor--
2024-04-02 00:53:29 -06:00
if m.cursor == 0 {
m.content.GotoTop()
} else {
tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor], 1)
2024-04-02 00:53:29 -06:00
}
m.content.SetContent(m.renderConversationList())
2024-04-02 00:53:29 -06:00
} else {
m.cursor = 0
m.content.GotoTop()
}
return true, nil
case "n":
// new conversation
case "d":
if !m.confirmPrompt.Focused() && len(m.conversations) > 0 && m.cursor < len(m.conversations) {
title := m.conversations[m.cursor].conv.Title
if title == "" {
title = "(untitled)"
}
m.confirmPrompt = bubbles.NewConfirmPrompt(
fmt.Sprintf("Delete '%s'?", title),
deleteConversationPayload(m.conversations[m.cursor].conv),
)
m.confirmPrompt.Style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("3"))
return true, nil
}
case "c":
// copy/clone conversation
case "r":
// show prompt to rename conversation
case "shift+r":
// show prompt to generate name for conversation
}
2024-03-31 19:06:13 -06:00
return false, nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m *Model) HandleResize(width, height int) {
m.Width, m.Height = width, height
m.content.Width = width
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case shared.MsgViewEnter:
cmds = append(cmds, m.loadConversations())
m.content.SetContent(m.renderConversationList())
case tea.WindowSizeMsg:
m.HandleResize(msg.Width, msg.Height)
m.content.SetContent(m.renderConversationList())
case msgConversationsLoaded:
m.conversations = msg
m.cursor = max(0, min(len(m.conversations), m.cursor))
m.content.SetContent(m.renderConversationList())
2024-05-30 01:18:31 -06:00
case msgConversationSelected:
m.Values.ConvShortname = msg.ShortName.String
cmds = append(cmds, func() tea.Msg {
return shared.MsgViewChange(shared.StateChat)
})
case bubbles.MsgConfirmPromptAnswered:
m.confirmPrompt.Blur()
if msg.Value {
switch payload := msg.Payload.(type) {
case deleteConversationPayload:
cmds = append(cmds, m.deleteConversation(api.Conversation(payload)))
}
}
case msgConversationDeleted:
cmds = append(cmds, m.loadConversations())
}
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if m.Width > 0 {
wrap := lipgloss.NewStyle().Width(m.Width)
2024-05-30 01:04:55 -06:00
m.Header = m.headerView()
m.Footer = "" // TODO: "Press ? for help"
if m.confirmPrompt.Focused() {
m.Footer = wrap.Render(m.confirmPrompt.View())
}
2024-05-30 01:04:55 -06:00
m.Error = tuiutil.ErrorBanner(m.Err, m.Width)
fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer)
m.content.Height = m.Height - fixedHeight
2024-05-30 01:04:55 -06:00
m.Content = m.content.View()
}
return m, tea.Batch(cmds...)
}
func (m *Model) loadConversations() tea.Cmd {
return func() tea.Msg {
messages, err := m.Ctx.Store.LatestConversationMessages()
if err != nil {
return shared.MsgError(fmt.Errorf("Could not load conversations: %v", err))
}
loaded := make([]loadedConversation, len(messages))
for i, m := range messages {
loaded[i].lastReply = m
loaded[i].conv = *m.Conversation
2024-04-01 15:26:45 -06:00
}
return msgConversationsLoaded(loaded)
}
}
func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd {
return func() tea.Msg {
err := m.Ctx.Store.DeleteConversation(&conv)
if err != nil {
return shared.MsgError(fmt.Errorf("Could not delete conversation: %v", err))
}
return msgConversationDeleted{}
}
}
2024-05-30 01:04:55 -06:00
func (m Model) View() string {
if m.Width == 0 {
return ""
}
sections := make([]string, 0, 6)
if m.Header != "" {
sections = append(sections, m.Header)
}
sections = append(sections, m.Content)
if m.Error != "" {
sections = append(sections, m.Error)
}
if m.Footer != "" {
sections = append(sections, m.Footer)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (m *Model) headerView() string {
titleStyle := lipgloss.NewStyle().Bold(true)
header := titleStyle.Render("Conversations")
return styles.Header.Width(m.Width).Render(header)
}
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())
2024-04-03 01:10:41 -06:00
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()
2024-04-01 15:26:45 -06:00
titleStyle := lipgloss.NewStyle().Bold(true)
untitledStyle := lipgloss.NewStyle().Faint(true).Italic(true)
selectedStyle := lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6"))
2024-04-03 01:10:41 -06:00
var (
currentOffset int
currentCategory string
sb strings.Builder
)
2024-04-02 00:53:29 -06:00
m.itemOffsets = make([]int, len(m.conversations))
sb.WriteRune('\n')
currentOffset += 1
2024-04-03 01:10:41 -06:00
for i, c := range m.conversations {
2024-04-01 15:26:45 -06:00
lastReplyAge := now.Sub(c.lastReply.CreatedAt)
2024-04-01 15:26:45 -06:00
var category string
for _, g := range categories {
if lastReplyAge < g.cutoff {
category = g.name
break
}
}
// print the category
2024-04-01 15:26:45 -06:00
if category != currentCategory {
currentCategory = category
2024-04-02 00:53:29 -06:00
heading := categoryStyle.Render(currentCategory)
sb.WriteString(heading)
currentOffset += tuiutil.Height(heading)
2024-04-02 00:53:29 -06:00
sb.WriteRune('\n')
2024-04-01 15:26:45 -06:00
}
tStyle := titleStyle.Copy()
2024-04-01 15:26:45 -06:00
if c.conv.Title == "" {
tStyle = tStyle.Inherit(untitledStyle).SetString("(untitled)")
}
if i == m.cursor {
tStyle = tStyle.Inherit(selectedStyle)
2024-04-01 15:26:45 -06:00
}
title := tStyle.Width(m.Width - 3).PaddingLeft(2).Render(c.conv.Title)
if i == m.cursor {
title = ">" + title[1:]
}
2024-04-02 00:53:29 -06:00
m.itemOffsets[i] = currentOffset
item := itemStyle.Render(fmt.Sprintf(
"%s\n %s",
title,
ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)),
2024-04-02 00:53:29 -06:00
))
sb.WriteString(item)
currentOffset += tuiutil.Height(item)
2024-04-02 00:53:29 -06:00
if i < len(m.conversations)-1 {
sb.WriteRune('\n')
}
2024-04-01 15:26:45 -06:00
}
return sb.String()
}