diff --git a/pkg/tui/conversation_list.go b/pkg/tui/conversation_list.go new file mode 100644 index 0000000..4887e22 --- /dev/null +++ b/pkg/tui/conversation_list.go @@ -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() +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 10b4d42..6669902 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -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 {