Files
paperclip-harness/tui/reply.go

459 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
names map[string]string
}
type replySentMsg struct{ task paperclip.Issue }
type replyModel struct {
panel string
client *paperclip.Client
folder string
issue string
explicit bool
task paperclip.Issue
names map[string]string
thread []paperclip.Comment
box string
inBox bool
next string
sending bool
notice string
err error
readAt time.Time
showHelp bool
scroll int
width int
height int
}
func newReplyModel(c *paperclip.Client, folder, issueID, panel string) replyModel {
return replyModel{client: c, folder: folder, issue: issueID, explicit: issueID != "",
panel: panel, inBox: panel == "reply"}
}
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}
}
names := map[string]string{}
if agents, err := c.Agents(); err == nil {
for _, a := range agents {
names[a.ID] = a.Name
}
}
return replyThreadMsg{task: task, comments: cs, names: names}
}
}
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 "R":
if err := startRebuild(m.folder); err != nil {
m.err = err
return m, nil
}
return m, tea.Quit
case "esc":
m.notice = ""
return m, nil
}
case replyThreadMsg:
m.task = msg.task
m.names = msg.names
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
}
// singleView is one panel filling the pane: the task, the thread, or the box.
func (m replyModel) singleView() string {
title := "thread"
var body []string
footer := ""
switch m.panel {
case "task":
title = "the task"
body = m.headerLines(m.width - 3)
footer = hintLine([]string{keyHint("r", "read again"), keyHint("R", "rebuild")}, m.width)
case "reply":
title = "reply · " + m.statusNote()
body = m.composerLines(m.width-3, m.height-3)
footer = hintLine([]string{
keyHint("enter", "send"),
keyHint("alt+enter", "new line"),
keyHint("s", "status"),
}, m.width)
if m.notice != "" {
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
}
if m.err != nil {
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(truncateVisible("error: "+m.err.Error(), m.width))
}
default:
body = m.threadLines(m.width - 3)
footer = hintLine([]string{
keyHint("↑↓ j k", "scroll"),
keyHint("r", "read again"),
keyHint("R", "rebuild"),
keyHint("?", "keys"),
}, m.width)
}
if m.showHelp {
return helpOverlay(title, [][2]string{
{"enter", "write a reply"}, {"s", "status to send with it"},
{"↑ ↓ j k", "scroll"}, {"r / R", "read again / rebuild"},
}, m.width, m.height)
}
return singlePanel(title, nil, 0, body, m.width, m.height, footer, m.scroll)
}
func (m replyModel) statusNote() string {
if m.next == "" {
return "leaves the status alone"
}
return "also sets the status to " + m.next
}
func (m replyModel) View() string {
if m.panel != "" {
if m.width == 0 {
return "reading…"
}
return m.singleView()
}
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))
}
if m.showHelp {
return helpOverlay("the conversation", [][2]string{
{"enter", "write a reply"},
{"alt+enter", "new line inside the box"},
{"s", "choose the status to send with it"},
{"↑ ↓ j k", "read the thread"},
{"r", "read again"},
{"R", "rebuild every pane"},
{"q", "leave"},
{"?", "this list"},
}, 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))
headerHeight := 5
threadHeight := m.height - composerHeight - headerHeight - 2
headerPanel := panel{title: "the task", width: m.width, height: headerHeight}
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("R", "rebuild every pane"),
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),
headerPanel.render(m.headerLines(m.width - 3)),
thread.render(lines[from:to]),
composer.render(boxLines),
footer,
}, "\n")
}
// headerLines is the panel the conversation needed: what task this is, without
// having to go back to the Board to remember.
func (m replyModel) headerLines(width int) []string {
if m.task.Identifier == "" {
return []string{"", mutedStyle2().Render("no task chosen yet")}
}
who := m.names[m.task.AssigneeAgent]
if who == "" {
who = "nobody yet"
}
status, _ := shortStatus(m.task.Status)
line1 := " " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Bold(true).
Render(m.task.Identifier) + " " + status + " " + mutedStyle2().Render("assigned to "+who)
out := []string{"", line1}
out = append(out, " "+truncateVisible(m.task.Title, max(8, width-2)))
if cmd := verifyFromDescription(m.task.Description); cmd != "" {
out = append(out, " "+dimStyle().Render("done when ")+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info)).Render(cmd))
}
return out
}
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
}