tui: Initial rough conversation list view
This commit is contained in:
parent
cef87a55d8
commit
c68cb14eb9
184
pkg/tui/conversation_list.go
Normal file
184
pkg/tui/conversation_list.go
Normal file
@ -0,0 +1,184 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model"
|
||||
"git.mlow.ca/mlow/lmcli/pkg/util"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m *model) handleConversationListInput(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
m.state = stateConversation
|
||||
m.updateContent()
|
||||
// load selected conversation and switch state
|
||||
case "n":
|
||||
// new conversation
|
||||
case "d":
|
||||
// show prompt to delete conversation
|
||||
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) handleConversationListUpdate(msg tea.Msg) []tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case msgConversationsLoaded:
|
||||
m.conversations = msg
|
||||
m.content.SetContent(m.conversationListView())
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.content, cmd = m.content.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if m.width > 0 {
|
||||
m.views.header = m.headerView()
|
||||
m.views.footer = m.footerView()
|
||||
m.views.error = m.errorView()
|
||||
fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer)
|
||||
|
||||
m.content.Height = m.height - fixedHeight
|
||||
m.views.content = m.content.View()
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *model) loadConversations() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
c, err := m.ctx.Store.Conversations()
|
||||
if err != nil {
|
||||
return msgError(fmt.Errorf("Could not load conversations: %v", err))
|
||||
}
|
||||
return msgConversationsLoaded(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) loadConversationLastReplies(conversations []models.Conversation) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
//lastMessage, err := m.ctx.Store.LastMessage(&conversation)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) setConversations(conversations []models.Conversation) {
|
||||
}
|
||||
|
||||
func (m *model) conversationListView() string {
|
||||
sb := &strings.Builder{}
|
||||
type AgeGroup struct {
|
||||
name string
|
||||
cutoff time.Duration
|
||||
}
|
||||
|
||||
type ConversationLine struct {
|
||||
id uint
|
||||
short string
|
||||
title string
|
||||
elapsed string
|
||||
lastReplyAge time.Duration
|
||||
}
|
||||
|
||||
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 := []AgeGroup{
|
||||
{"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{})},
|
||||
}
|
||||
categorized := map[string][]ConversationLine{}
|
||||
|
||||
for _, conversation := range m.conversations {
|
||||
lastMessage, err := m.ctx.Store.LastMessage(&conversation)
|
||||
if lastMessage == nil || err != nil {
|
||||
continue
|
||||
}
|
||||
messageAge := now.Sub(lastMessage.CreatedAt)
|
||||
var category string
|
||||
for _, c := range categories {
|
||||
if messageAge < c.cutoff {
|
||||
category = c.name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
categorized[category] = append(
|
||||
categorized[category],
|
||||
ConversationLine{
|
||||
id: conversation.ID,
|
||||
title: conversation.Title,
|
||||
short: conversation.ShortName.String,
|
||||
lastReplyAge: messageAge,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: pick nice color
|
||||
categoryStyle := lipgloss.NewStyle().
|
||||
MarginBottom(1).
|
||||
Foreground(lipgloss.Color("170")).
|
||||
PaddingLeft(1).
|
||||
Bold(true)
|
||||
|
||||
conversationHeadingStyle := lipgloss.NewStyle().
|
||||
MarginBottom(1).
|
||||
PaddingLeft(2)
|
||||
|
||||
for _, category := range categories {
|
||||
conversationLines, ok := categorized[category.name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
slices.SortFunc(conversationLines, func(a, b ConversationLine) int {
|
||||
return int(a.lastReplyAge - b.lastReplyAge)
|
||||
})
|
||||
|
||||
ageStyle := lipgloss.NewStyle().Faint(true)
|
||||
titleStyle := lipgloss.NewStyle().Bold(true)
|
||||
untitledStyle := titleStyle.Copy().Italic(true).SetString("(untitled)")
|
||||
|
||||
fmt.Fprintf(sb, "%s\n", categoryStyle.Render(category.name))
|
||||
for _, c := range conversationLines {
|
||||
tstyle := titleStyle
|
||||
if c.title == "" {
|
||||
tstyle = untitledStyle
|
||||
}
|
||||
heading := fmt.Sprintf(
|
||||
"%s\n%s",
|
||||
tstyle.Render(c.title),
|
||||
ageStyle.Render(util.HumanTimeElapsedSince(c.lastReplyAge)),
|
||||
)
|
||||
sb.WriteString(conversationHeadingStyle.Render(heading))
|
||||
sb.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
@ -32,7 +32,7 @@ type appState int
|
||||
|
||||
const (
|
||||
stateConversation = iota
|
||||
//stateConversationList
|
||||
stateConversationList
|
||||
//stateModelSelect // stateOptions?
|
||||
//stateHelp
|
||||
)
|
||||
@ -60,6 +60,8 @@ type model struct {
|
||||
|
||||
// application state
|
||||
state appState
|
||||
conversations []models.Conversation
|
||||
lastReplies []models.Message
|
||||
conversation *models.Conversation
|
||||
messages []models.Message
|
||||
selectedMessage int
|
||||
@ -143,6 +145,8 @@ type (
|
||||
msgConversationTitleChanged string
|
||||
// sent when a conversation's messages are laoded
|
||||
msgMessagesLoaded []models.Message
|
||||
// send when conversation list is loaded
|
||||
msgConversationsLoaded []models.Conversation
|
||||
// sent when an error occurs
|
||||
msgError error
|
||||
)
|
||||
@ -154,13 +158,21 @@ func wrapError(err error) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
cmds := []tea.Cmd{
|
||||
textarea.Blink,
|
||||
m.spinner.Tick,
|
||||
m.loadConversation(m.convShortname),
|
||||
m.waitForChunk(),
|
||||
m.waitForReply(),
|
||||
)
|
||||
}
|
||||
switch m.state {
|
||||
case stateConversation:
|
||||
if m.convShortname != "" {
|
||||
cmds = append(cmds, m.loadConversation(m.convShortname))
|
||||
}
|
||||
case stateConversationList:
|
||||
cmds = append(cmds, m.loadConversations())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
||||
@ -181,6 +193,8 @@ func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
||||
switch m.state {
|
||||
case stateConversation:
|
||||
return m.handleConversationInput(msg)
|
||||
case stateConversationList:
|
||||
return m.handleConversationListInput(msg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@ -188,6 +202,9 @@ func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd {
|
||||
|
||||
func (m *model) handleConversationInput(msg tea.KeyMsg) tea.Cmd {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.state = stateConversationList
|
||||
return m.loadConversations()
|
||||
case "ctrl+p":
|
||||
m.persistence = !m.persistence
|
||||
case "ctrl+t":
|
||||
@ -209,10 +226,6 @@ func (m *model) handleConversationInput(msg tea.KeyMsg) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) handleConversationListInput(msg tea.KeyMsg) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) handleConversationUpdate(msg tea.Msg) []tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
@ -395,6 +408,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateConversationList:
|
||||
cmds = append(cmds, m.handleConversationListUpdate(msg)...)
|
||||
case stateConversation:
|
||||
cmds = append(cmds, m.handleConversationUpdate(msg)...)
|
||||
}
|
||||
@ -439,6 +454,11 @@ func (m model) View() string {
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateConversationList:
|
||||
sections = append(sections, m.views.content)
|
||||
if m.views.error != "" {
|
||||
sections = append(sections, m.views.error)
|
||||
}
|
||||
case stateConversation:
|
||||
sections = append(sections, m.views.content)
|
||||
if m.views.error != "" {
|
||||
@ -451,23 +471,26 @@ func (m model) View() string {
|
||||
sections = append(sections, m.views.footer)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
sections...,
|
||||
)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
}
|
||||
|
||||
func (m *model) headerView() string {
|
||||
titleStyle := lipgloss.NewStyle().Bold(true)
|
||||
var title string
|
||||
if m.conversation != nil && m.conversation.Title != "" {
|
||||
title = m.conversation.Title
|
||||
} else {
|
||||
title = "Untitled"
|
||||
var header string
|
||||
switch m.state {
|
||||
case stateConversation:
|
||||
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 stateConversationList:
|
||||
header = titleStyle.Render("Conversations")
|
||||
}
|
||||
title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...")
|
||||
part := titleStyle.Render(title)
|
||||
return headerStyle.Width(m.width).Render(part)
|
||||
return headerStyle.Width(m.width).Render(header)
|
||||
}
|
||||
|
||||
func (m *model) errorView() string {
|
||||
|
Loading…
Reference in New Issue
Block a user