Conversation and Author screens styled to match; glyph policy chosen from measurement
This commit is contained in:
324
tui/reply.go
324
tui/reply.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user