package main // The Conversation workspace: what has been said on the chosen task, and a box // to say something back. // // Two panels: the thread, and the reply box. Sending writes a comment as the // person and, when a status has been chosen, changes the task's status in the // same call — which is what wakes an agent. That pair is the whole point of this // screen, so both are shown before the key is pressed. import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "paperclip-harness/tui/paperclip" ) type replyThreadMsg struct { task paperclip.Issue comments []paperclip.Comment } type replySentMsg struct{ task paperclip.Issue } type replyModel struct { client *paperclip.Client folder string issue string explicit bool task paperclip.Issue thread []paperclip.Comment box string inBox bool next string sending bool notice string err error readAt time.Time scroll int width int height int } func newReplyModel(c *paperclip.Client, folder, issueID string) replyModel { return replyModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""} } func (m replyModel) Init() tea.Cmd { return tea.Batch(m.load(), tickEvery(5*time.Second)) } func (m replyModel) load() tea.Cmd { c, folder := m.client, m.folder fixed, explicit := m.issue, m.explicit return func() tea.Msg { id := fixed if !explicit { id = watchedTask(folder) } if id == "" { return errMsg{fmt.Errorf("no task chosen: pick one on the Board, then press Enter")} } task, err := c.Issue(id) if err != nil { return errMsg{err} } cs, err := c.Comments(id) if err != nil { return errMsg{err} } return replyThreadMsg{task: task, comments: cs} } } func (m replyModel) send() tea.Cmd { c, id, body, status := m.client, m.task.ID, strings.TrimSpace(m.box), m.next return func() tea.Msg { var task paperclip.Issue var err error if status != "" { task, err = c.SetStatus(id, status, body) } else { _, err = c.PostComment(id, body) } if err != nil { return errMsg{err} } if task.ID == "" { task, err = c.Issue(id) if err != nil { return errMsg{err} } } return replySentMsg{task: task} } } var replyStatuses = []string{"", "todo", "in_progress", "in_review", "done", "blocked"} func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil case tickMsg: if !m.inBox { return m, tea.Batch(m.load(), tickEvery(5*time.Second)) } return m, tickEvery(5*time.Second) case tea.KeyMsg: if m.inBox { switch msg.Type { case tea.KeyEsc: m.inBox = false return m, nil case tea.KeyEnter: if msg.Alt { m.box += "\n" return m, nil } if m.sending { return m, nil } if strings.TrimSpace(m.box) == "" { m.notice = "nothing to send yet" return m, nil } m.sending = true m.notice = "" return m, m.send() case tea.KeyBackspace: r := []rune(m.box) if len(r) > 0 { m.box = string(r[:len(r)-1]) } return m, nil case tea.KeySpace: m.box += " " return m, nil case tea.KeyTab: m.box += " " return m, nil case tea.KeyRunes: m.box += string(msg.Runes) return m, nil } return m, nil } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "tab", "enter": m.inBox = true m.notice = "" return m, nil case "up", "k": m.scroll-- return m, nil case "down", "j": m.scroll++ return m, nil case "s": for i, st := range replyStatuses { if st == m.next { m.next = replyStatuses[(i+1)%len(replyStatuses)] break } } return m, nil case "r": return m, m.load() case "esc": m.notice = "" return m, nil } case replyThreadMsg: m.task = msg.task wasEmpty := len(m.thread) m.thread = msg.comments m.readAt = time.Now() m.err = nil if len(m.thread) != wasEmpty { m.scroll = 1 << 30 } return m, nil case replySentMsg: m.sending = false m.box = "" m.next = "" m.task = msg.task m.notice = "sent" m.inBox = false return m, m.load() case errMsg: m.sending = false m.err = msg.err return m, nil } return m, nil } func (m replyModel) View() string { if m.width == 0 || m.height == 0 { return "reading…" } if m.width < 80 || m.height < 24 { return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height)) } head := titleStyle.Render("Conversation") + mutedStyle2().Render(" · ") if m.task.Identifier != "" { head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)). Render(m.task.Identifier + " " + truncateVisible(m.task.Title, max(10, m.width-60))) } state := "read " + m.readAt.Format("15:04:05") if m.readAt.IsZero() { state = "reading…" } composerHeight := max(5, min(9, m.height/4)) threadHeight := m.height - composerHeight - 2 thread := panel{ title: fmt.Sprintf("thread · %s", fmtCount(len(m.thread), "message", "messages")), width: m.width, height: threadHeight, focused: !m.inBox, } lines := m.threadLines(m.width - 4) visible := thread.bodyHeight() maxScroll := max(0, len(lines)-visible) if m.scroll > maxScroll { m.scroll = maxScroll } if m.scroll < 0 { m.scroll = 0 } from := m.scroll to := min(len(lines), from+visible) statusNote := "leaves the status alone" if m.next != "" { statusNote = "also sets the status to " + m.next } composer := panel{ title: fmt.Sprintf("reply · %s", statusNote), width: m.width, height: composerHeight, focused: m.inBox, } boxLines := m.composerLines(m.width - 4, composer.bodyHeight()) footer := hintLine([]string{ keyHint("enter or tab", "write a reply"), keyHint("s", "choose a status to send"), keyHint("↑↓ or j k", "read the thread"), keyHint("r", "read again"), keyHint("q", "leave"), }, m.width) if m.inBox { footer = hintLine([]string{ keyHint("enter", "send"), keyHint("alt+enter", "new line"), keyHint("esc", "close the box"), }, m.width) } if m.err != nil { footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(truncateVisible("error: "+m.err.Error(), m.width)) } else if m.sending { footer = mutedStyle2().Render("sending…") } else if m.notice != "" { footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice) } gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1) return strings.Join([]string{ head + strings.Repeat(" ", gap) + mutedStyle2().Render(state), thread.render(lines[from:to]), composer.render(boxLines), footer, }, "\n") } func (m replyModel) composerLines(width, height int) []string { text := m.box if text == "" { return []string{mutedStyle2().Render("write a reply, then press enter")} } var out []string for _, para := range strings.Split(text, "\n") { for _, ln := range wrap(para, width) { out = append(out, ln) } } // The cursor sits at the end of what has been typed. if len(out) > 0 { out[len(out)-1] += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render("▌") } if len(out) > height { out = out[len(out)-height:] } return out } func (m replyModel) threadLines(width int) []string { if m.err != nil { return []string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(m.err.Error())} } if len(m.thread) == 0 { return []string{"", mutedStyle2().Render("nothing said on this task yet")} } var out []string for _, c := range m.thread { who, colour := "person", active.Accent switch c.AuthorType { case "agent": who, colour = "agent", active.Mauve case "user": who, colour = "you", active.Info case "system": who, colour = "system", active.Muted } when := "" if t, err := time.Parse(time.RFC3339, c.CreatedAt); err == nil { when = t.Local().Format("15:04") } out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(who)+ dimStyle().Render(" "+when)) for _, ln := range strings.Split(c.Body, "\n") { for _, w := range wrap(ln, max(8, width-2)) { out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(w)) } } out = append(out, "") } return out }