327 lines
7.0 KiB
Go
327 lines
7.0 KiB
Go
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
|
||
}
|