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" ) type loadedConversation struct { conv api.Conversation lastReply api.Message } type ( // 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 { shared.Shared shared.Sections conversations []loadedConversation 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 } func Conversations(shared shared.Shared) Model { m := Model{ 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 { return msgConversationSelected(m.conversations[m.cursor].conv) } } case "j", "down": if m.cursor < len(m.conversations)-1 { m.cursor++ 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) } m.content.SetContent(m.renderConversationList()) } else { m.cursor = len(m.conversations) - 1 m.content.GotoBottom() } return true, nil 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 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 } 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()) 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) m.Header = m.headerView() m.Footer = "" // TODO: "Press ? for help" if m.confirmPrompt.Focused() { m.Footer = wrap.Render(m.confirmPrompt.View()) } 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 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 } 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{} } } 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()) 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.conversations)) sb.WriteRune('\n') currentOffset += 1 for i, c := range m.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.Copy() 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.conversations)-1 { sb.WriteRune('\n') } } return sb.String() }