Conversation and Author screens styled to match; glyph policy chosen from measurement

This commit is contained in:
Sam
2026-11-10 14:18:33 +11:00
parent 4e3c955350
commit 031073fc46
13 changed files with 755 additions and 240 deletions

View File

@@ -1,60 +1,71 @@
package main
// Screen 4: the task's conversation, with a box to reply in.
// The Conversation workspace: what has been said on the chosen task, and a box
// to say something back.
//
// 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.
// 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"
zone "github.com/lrstanley/bubblezone"
"paperclip-harness/tui/paperclip"
)
type threadMsg struct {
issue paperclip.Issue
type replyThreadMsg struct {
task paperclip.Issue
comments []paperclip.Comment
}
type sentMsg struct{ issue paperclip.Issue }
type replySentMsg struct{ task paperclip.Issue }
type replyModel struct {
client *paperclip.Client
issue string
zones *zone.Manager
client *paperclip.Client
folder string
issue string
explicit bool
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
task paperclip.Issue
thread []paperclip.Comment
box string
inBox bool
next string
sending bool
notice string
err error
readAt time.Time
width, height int
scroll int
scroll int
width int
height int
}
func newReplyModel(c *paperclip.Client, issueID string) replyModel {
return replyModel{
client: c,
issue: issueID,
zones: zone.New(),
}
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 m.load() }
func (m replyModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
}
func (m replyModel) load() tea.Cmd {
c, id := m.client, m.issue
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}
@@ -63,17 +74,15 @@ func (m replyModel) load() tea.Cmd {
if err != nil {
return errMsg{err}
}
return threadMsg{issue: task, comments: cs}
return replyThreadMsg{task: task, comments: cs}
}
}
func (m replyModel) send() tea.Cmd {
c, id, body, status := m.client, m.issue, strings.TrimSpace(m.box), m.next
c, id, body, status := m.client, m.task.ID, strings.TrimSpace(m.box), m.next
return func() tea.Msg {
var (
task paperclip.Issue
err error
)
var task paperclip.Issue
var err error
if status != "" {
task, err = c.SetStatus(id, status, body)
} else {
@@ -88,11 +97,10 @@ func (m replyModel) send() tea.Cmd {
return errMsg{err}
}
}
return sentMsg{issue: task}
return replySentMsg{task: 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) {
@@ -101,24 +109,28 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
// While the box has focus it owns the ordinary keys, so typing a
// message never triggers the screen's own shortcuts.
if m.focusBox {
if m.inBox {
switch msg.Type {
case tea.KeyEsc:
lost := m.box == ""
m.focusBox = false
if lost {
m.notice = ""
}
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"
m.notice = "nothing to send yet"
return m, nil
}
m.sending = true
@@ -133,7 +145,10 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeySpace:
m.box += " "
return m, nil
case tea.KeyRunes, tea.KeyTab:
case tea.KeyTab:
m.box += " "
return m, nil
case tea.KeyRunes:
m.box += string(msg.Runes)
return m, nil
}
@@ -143,14 +158,14 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "tab", "shift+tab", "enter":
m.focusBox = true
case "tab", "enter":
m.inBox = true
m.notice = ""
return m, nil
case "up":
case "up", "k":
m.scroll--
return m, nil
case "down":
case "down", "j":
m.scroll++
return m, nil
case "s":
@@ -164,21 +179,28 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "r":
return m, m.load()
case "esc":
m.notice = ""
return m, nil
}
case threadMsg:
m.task = msg.issue
case replyThreadMsg:
m.task = msg.task
wasEmpty := len(m.thread)
m.thread = msg.comments
m.scroll = 1 << 30 // pinned to the bottom until the person scrolls up
m.readAt = time.Now()
m.err = nil
if len(m.thread) != wasEmpty {
m.scroll = 1 << 30
}
return m, nil
case sentMsg:
case replySentMsg:
m.sending = false
m.box = ""
m.next = ""
m.task = msg.issue
m.task = msg.task
m.notice = "sent"
m.inBox = false
return m, m.load()
case errMsg:
@@ -189,33 +211,35 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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…"
return "reading…"
}
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)))
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))
}
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")
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…"
}
boxHeight := 3
footer := 2
threadRows := max(3, m.height-boxHeight-footer-2)
composerHeight := max(5, min(9, m.height/4))
threadHeight := m.height - composerHeight - 2
lines := m.threadLines(m.width - 2)
// Clamp the scroll to what exists.
maxScroll := max(0, len(lines)-threadRows)
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
}
@@ -223,104 +247,100 @@ func (m replyModel) View() string {
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")
}
to := min(len(lines), from+visible)
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"
statusNote := "leaves the status alone"
if m.next != "" {
status = "status → " + m.next
statusNote = "also sets the status to " + 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…"
composer := panel{
title: fmt.Sprintf("reply · %s", statusNote),
width: m.width, height: composerHeight, focused: m.inBox,
}
if m.err != nil {
b.WriteString(blockedStyle.Render(truncate(line, m.width)))
} else {
b.WriteString(mutedStyle.Render(truncate(line, m.width)))
}
b.WriteString("\n")
boxLines := m.composerLines(m.width - 4, composer.bodyHeight())
return m.zones.Scan(b.String())
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("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
}
// threadLines turns the comments into wrapped lines the pane can scroll.
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{mutedStyle.Render("no messages yet")}
return []string{"", mutedStyle2().Render("nothing said on this task yet")}
}
var out []string
for _, c := range m.thread {
who := "person"
who, colour := "person", active.Accent
switch c.AuthorType {
case "agent":
who = "agent " + short(c.AuthorID)
who, colour = "agent", active.Mauve
case "user":
who = "you"
who, colour = "you", active.Info
case "system":
who = "system"
who, colour = "system", active.Muted
}
out = append(out, fmt.Sprintf("%s %s", groupStyle.Render(who), mutedStyle.Render(short(c.ID))))
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, " "+w)
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(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
}