Files
paperclip-harness/tui/reply.go

354 lines
8.4 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
}
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 "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
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("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),
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
}