package main // Screen 4: the task's conversation, with a box to reply in. // // Sending writes a comment as the person and, if a status was chosen, changes // the task's status in the same call. That pair is what wakes an agent: it sees // a new comment on a task it owns, and it sees the task waiting on it again. import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" zone "github.com/lrstanley/bubblezone" "paperclip-harness/tui/paperclip" ) type threadMsg struct { issue paperclip.Issue comments []paperclip.Comment } type sentMsg struct{ issue paperclip.Issue } type replyModel struct { client *paperclip.Client issue string zones *zone.Manager task paperclip.Issue thread []paperclip.Comment box string focusBox bool next string // status to send with the reply; "" means leave it alone sending bool notice string err error width, height int scroll int } func newReplyModel(c *paperclip.Client, issueID string) replyModel { return replyModel{ client: c, issue: issueID, zones: zone.New(), } } func (m replyModel) Init() tea.Cmd { return m.load() } func (m replyModel) load() tea.Cmd { c, id := m.client, m.issue return func() tea.Msg { task, err := c.Issue(id) if err != nil { return errMsg{err} } cs, err := c.Comments(id) if err != nil { return errMsg{err} } return threadMsg{issue: task, comments: cs} } } func (m replyModel) send() tea.Cmd { c, id, body, status := m.client, m.issue, strings.TrimSpace(m.box), m.next return func() tea.Msg { var ( task paperclip.Issue 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 sentMsg{issue: task} } } // statuses the reply box can ask for, in the order they are offered. 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 tea.KeyMsg: // While the box has focus it owns the ordinary keys, so typing a // message never triggers the screen's own shortcuts. if m.focusBox { switch msg.Type { case tea.KeyEsc: lost := m.box == "" m.focusBox = false if lost { m.notice = "" } return m, nil case tea.KeyEnter: if m.sending { return m, nil } if strings.TrimSpace(m.box) == "" { m.notice = "nothing to send" 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.KeyRunes, tea.KeyTab: m.box += string(msg.Runes) return m, nil } return m, nil } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "tab", "shift+tab", "enter": m.focusBox = true m.notice = "" return m, nil case "up": m.scroll-- return m, nil case "down": 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": return m, nil } case threadMsg: m.task = msg.issue m.thread = msg.comments m.scroll = 1 << 30 // pinned to the bottom until the person scrolls up return m, nil case sentMsg: m.sending = false m.box = "" m.next = "" m.task = msg.issue m.notice = "sent" return m, m.load() case errMsg: m.sending = false m.err = msg.err return m, nil } return m, nil } var ( boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1) boxFocusStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("12")).Padding(0, 1) ) func (m replyModel) View() string { if m.width == 0 || m.height == 0 { return "loading…" } if m.width < minWidth || m.height < minHeight { return m.zones.Scan(lipgloss.NewStyle().Width(m.width).Height(m.height). Render(fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height))) } var b strings.Builder head := fmt.Sprintf("%s %s · conversation", m.task.Identifier, m.task.Status) b.WriteString(titleStyle.Render(truncate(head, m.width))) b.WriteString("\n") boxHeight := 3 footer := 2 threadRows := max(3, m.height-boxHeight-footer-2) lines := m.threadLines(m.width - 2) // Clamp the scroll to what exists. maxScroll := max(0, len(lines)-threadRows) if m.scroll > maxScroll { m.scroll = maxScroll } if m.scroll < 0 { m.scroll = 0 } from := m.scroll to := min(len(lines), from+threadRows) shown := lines[from:to] for len(shown) < threadRows { shown = append(shown, "") } for _, ln := range shown { b.WriteString(ln) b.WriteString("\n") } box := m.box if box == "" { box = "Tab or Enter to write a reply" } style := boxStyle if m.focusBox { style = boxFocusStyle } b.WriteString(style.Width(m.width - 2).Render(truncate(box, m.width-6))) b.WriteString("\n") status := "no status change" if m.next != "" { status = "status → " + m.next } line := fmt.Sprintf("%s · %s", status, "s changes status · r reload · q quit") if m.err != nil { line = "error: " + m.err.Error() } else if m.notice != "" { line = m.notice } else if m.sending { line = "sending…" } if m.err != nil { b.WriteString(blockedStyle.Render(truncate(line, m.width))) } else { b.WriteString(mutedStyle.Render(truncate(line, m.width))) } b.WriteString("\n") return m.zones.Scan(b.String()) } // threadLines turns the comments into wrapped lines the pane can scroll. func (m replyModel) threadLines(width int) []string { if len(m.thread) == 0 { return []string{mutedStyle.Render("no messages yet")} } var out []string for _, c := range m.thread { who := "person" switch c.AuthorType { case "agent": who = "agent " + short(c.AuthorID) case "user": who = "you" case "system": who = "system" } out = append(out, fmt.Sprintf("%s %s", groupStyle.Render(who), mutedStyle.Render(short(c.ID)))) for _, ln := range strings.Split(c.Body, "\n") { for _, w := range wrap(ln, max(8, width-2)) { out = append(out, " "+w) } } out = append(out, "") } return out } func short(id string) string { if len(id) <= 8 { return id } return id[:8] } func wrap(s string, w int) []string { if s == "" { return []string{""} } words := strings.Fields(s) var lines []string cur := "" for _, word := range words { if cur == "" { cur = word continue } if len([]rune(cur))+1+len([]rune(word)) <= w { cur += " " + word continue } lines = append(lines, cur) cur = word } if cur != "" { lines = append(lines, cur) } return lines }