Screens 1 and 4 running as TUIOS panes: reply screen, adopt-or-create launcher, layout script

This commit is contained in:
Sam
2026-11-10 11:48:07 +11:00
parent 8cfe38ee84
commit 34f60872f6
10 changed files with 564 additions and 23 deletions

326
tui/reply.go Normal file
View 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
}