Screens 1 and 4 running as TUIOS panes: reply screen, adopt-or-create launcher, layout script
This commit is contained in:
326
tui/reply.go
Normal file
326
tui/reply.go
Normal file
@@ -0,0 +1,326 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user