402 lines
10 KiB
Go
402 lines
10 KiB
Go
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 {
|
||
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 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), tea.WindowSize())
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
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
|
||
}
|