Conversation and Author screens styled to match; glyph policy chosen from measurement
This commit is contained in:
14
tui/board.go
14
tui/board.go
@@ -414,19 +414,19 @@ func (m boardModel) taskLine(r Row, index, width int) []string {
|
||||
func shortStatus(status string) (string, string) {
|
||||
switch status {
|
||||
case "in_progress":
|
||||
return "running", active.Info
|
||||
return icons.Running + " running", active.Info
|
||||
case "blocked":
|
||||
return "blocked", active.Error
|
||||
return icons.Blocked + " blocked", active.Error
|
||||
case "in_review":
|
||||
return "review", active.Warning
|
||||
return icons.Review + " review", active.Warning
|
||||
case "done":
|
||||
return "✓ done", active.Success
|
||||
return icons.Done + " done", active.Success
|
||||
case "cancelled":
|
||||
return "✕ gone", active.Overlay
|
||||
return icons.Cancelled + " gone", active.Overlay
|
||||
case "backlog":
|
||||
return "backlog", active.Muted
|
||||
return icons.Todo + " backlog", active.Muted
|
||||
default:
|
||||
return "todo", active.Text
|
||||
return icons.Todo + " todo", active.Text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
tui/frame.go
45
tui/frame.go
@@ -185,19 +185,19 @@ func statusPill(status string) string {
|
||||
func statusLabel(status string) (string, string) {
|
||||
switch status {
|
||||
case "in_progress":
|
||||
return "▶ running ", active.Info
|
||||
return icons.Running + " running ", active.Info
|
||||
case "blocked":
|
||||
return "■ blocked ", active.Error
|
||||
return icons.Blocked + " blocked ", active.Error
|
||||
case "in_review":
|
||||
return "◆ review ", active.Warning
|
||||
return icons.Review + " review ", active.Warning
|
||||
case "done":
|
||||
return "✓ done ", active.Success
|
||||
return icons.Done + " done ", active.Success
|
||||
case "cancelled":
|
||||
return "✕ cancel ", active.Overlay
|
||||
return icons.Cancelled + " cancel ", active.Overlay
|
||||
case "backlog":
|
||||
return "· backlog ", active.Muted
|
||||
return icons.Todo + " backlog ", active.Muted
|
||||
default:
|
||||
return "· todo ", active.Text
|
||||
return icons.Todo + " todo ", active.Text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +240,34 @@ func fmtCount(n int, one, many string) string {
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
|
||||
// wrap breaks one paragraph into lines no wider than w, on word boundaries.
|
||||
// It is used for anything a person wrote, so it never cuts a word in half.
|
||||
func wrap(s string, w int) []string {
|
||||
if w < 4 {
|
||||
w = 4
|
||||
}
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var lines []string
|
||||
cur := ""
|
||||
for _, word := range strings.Fields(s) {
|
||||
switch {
|
||||
case cur == "":
|
||||
cur = word
|
||||
case len([]rune(cur))+1+len([]rune(word)) <= w:
|
||||
cur += " " + word
|
||||
default:
|
||||
lines = append(lines, cur)
|
||||
cur = word
|
||||
}
|
||||
}
|
||||
if cur != "" {
|
||||
lines = append(lines, cur)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
20
tui/main.go
20
tui/main.go
@@ -22,12 +22,14 @@ func main() {
|
||||
screen := flag.String("screen", "board", "which screen to run: board, work, tree, reply, new or log")
|
||||
folder := flag.String("folder", "", "project folder, for the board screen (defaults to the current directory)")
|
||||
light := flag.String("theme", "", "colour variant: dark, light, or empty to follow the terminal")
|
||||
nerd := flag.Bool("nerd", false, "use Nerd Font icons instead of plain characters")
|
||||
agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder")
|
||||
project := flag.String("project", "", "project id or url key, for the tree screen")
|
||||
issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen")
|
||||
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
|
||||
flag.Parse()
|
||||
choosePalette(*light)
|
||||
chooseGlyphs(*nerd)
|
||||
|
||||
client, err := paperclip.New()
|
||||
if err != nil {
|
||||
@@ -95,12 +97,20 @@ func main() {
|
||||
m = newLogModel(client, task.ID)
|
||||
|
||||
case "reply":
|
||||
task, err := resolveIssue(client, *issue)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
dir := *folder
|
||||
if dir == "" {
|
||||
dir, _ = os.Getwd()
|
||||
}
|
||||
m = newReplyModel(client, task.ID)
|
||||
id := *issue
|
||||
if id != "" {
|
||||
task, err := resolveIssue(client, id)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
id = task.ID
|
||||
}
|
||||
m = newReplyModel(client, dir, id)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown screen %q: use tree, reply, new or log\n", *screen)
|
||||
|
||||
133
tui/newtask.go
133
tui/newtask.go
@@ -1,13 +1,12 @@
|
||||
package main
|
||||
|
||||
// Screen 2: make a task.
|
||||
// The Author workspace: write a task.
|
||||
//
|
||||
// The order the fields ask for is the order Paperclip needs them in: a title,
|
||||
// what it must do, what must finish first, and the command that proves it is
|
||||
// finished. The task is created unassigned and given its blocker in a separate
|
||||
// call, because a task assigned at creation is claimed by an agent's run within
|
||||
// seconds, and that run then owns the task's fields — the blocker write would be
|
||||
// refused.
|
||||
// One panel with five fields, in the order Paperclip needs them: a title, what
|
||||
// it must do, what must finish first, the command that proves it is done, and
|
||||
// whether to hand it to an agent. The task is created unassigned and given its
|
||||
// blocker in a separate call, because a task assigned at creation is claimed by
|
||||
// an agent's run within seconds and that run then owns the task's fields.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -23,6 +22,7 @@ import (
|
||||
type createdMsg struct {
|
||||
issue paperclip.Issue
|
||||
blocked string
|
||||
assigned bool
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ type newTaskModel struct {
|
||||
focus int
|
||||
err error
|
||||
busy bool
|
||||
|
||||
created *paperclip.Issue
|
||||
notice string
|
||||
result string
|
||||
|
||||
width, height int
|
||||
}
|
||||
@@ -51,20 +49,21 @@ const (
|
||||
fieldCount
|
||||
)
|
||||
|
||||
var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to an agent"}
|
||||
var fieldHints = []string{
|
||||
"one line, the thing to be done",
|
||||
"details the agent needs: files, behaviour, limits",
|
||||
"identifier of a task that must finish first, or empty",
|
||||
"a command that succeeds only when the work is right, or empty",
|
||||
"yes, to hand it to the configured agent after the blocker is set",
|
||||
}
|
||||
|
||||
func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) newTaskModel {
|
||||
placeholders := []string{
|
||||
"what needs doing, in one line",
|
||||
"details the agent needs: files, behaviour, limits",
|
||||
"identifier of the task that must finish first, or empty",
|
||||
"the command that proves it is done, or empty",
|
||||
"assign after the blocker is set: yes, or empty",
|
||||
}
|
||||
m := newTaskModel{client: c, proj: proj, agent: agentID}
|
||||
for _, l := range placeholders {
|
||||
for range fieldNames {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = l
|
||||
ti.Placeholder = ""
|
||||
ti.CharLimit = 1000
|
||||
ti.Width = 60
|
||||
ti.Prompt = ""
|
||||
m.inputs = append(m.inputs, ti)
|
||||
}
|
||||
@@ -72,8 +71,6 @@ func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) ne
|
||||
return m
|
||||
}
|
||||
|
||||
var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to"}
|
||||
|
||||
func (m newTaskModel) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (m newTaskModel) submit() tea.Cmd {
|
||||
@@ -87,9 +84,6 @@ func (m newTaskModel) submit() tea.Cmd {
|
||||
|
||||
desc := body
|
||||
if verify != "" {
|
||||
// The command lives with the task, in the task's own words, because
|
||||
// Paperclip owns the task. The project's .harness/verify.sh is the
|
||||
// convention it follows.
|
||||
desc += "\n\nDone when this command succeeds:\n\n " + verify
|
||||
}
|
||||
if blocker != "" {
|
||||
@@ -104,22 +98,20 @@ func (m newTaskModel) submit() tea.Cmd {
|
||||
if blocker != "" {
|
||||
b, err := resolveIssue(c, blocker)
|
||||
if err != nil {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker %q was not found: %w", is.Identifier, blocker, err)}
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but no task matches %q", is.Identifier, blocker)}
|
||||
}
|
||||
if _, err := c.SetBlockers(is.ID, []string{b.ID}); err != nil {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker was refused: %w", is.Identifier, err)}
|
||||
}
|
||||
}
|
||||
if assign != "" {
|
||||
if agent == "" {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but no agent is configured to assign to", is.Identifier)}
|
||||
}
|
||||
// Assigned last, on purpose: this is the call that wakes the agent.
|
||||
assigned := false
|
||||
if strings.EqualFold(assign, "yes") || strings.EqualFold(assign, "y") {
|
||||
if _, err := c.Assign(is.ID, agent); err != nil {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the assignment was refused: %w", is.Identifier, err)}
|
||||
}
|
||||
assigned = true
|
||||
}
|
||||
return createdMsg{issue: is, blocked: blocker}
|
||||
return createdMsg{issue: is, blocked: blocker, assigned: assigned}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +119,7 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
w := max(20, min(72, m.width-8))
|
||||
w := max(20, min(70, m.width-12))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i].Width = w
|
||||
}
|
||||
@@ -135,9 +127,7 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEsc:
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
return m, tea.Quit
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
m.inputs[m.focus].Blur()
|
||||
@@ -155,7 +145,8 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if strings.TrimSpace(m.inputs[fieldTitle].Value()) == "" {
|
||||
m.err = fmt.Errorf("a task needs a title")
|
||||
return m, nil
|
||||
m.focus = fieldTitle
|
||||
return m, m.inputs[m.focus].Focus()
|
||||
}
|
||||
if m.busy {
|
||||
return m, nil
|
||||
@@ -167,18 +158,18 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case createdMsg:
|
||||
m.busy = false
|
||||
m.err = msg.err
|
||||
if msg.err == nil {
|
||||
m.created = &msg.issue
|
||||
m.notice = "created " + msg.issue.Identifier
|
||||
if msg.blocked != "" {
|
||||
m.notice += " after " + msg.blocked
|
||||
}
|
||||
// Leave the form filled so a failed run can be corrected, but say
|
||||
// plainly what happened in the pane.
|
||||
return m, tea.Quit
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
m.result = "created " + msg.issue.Identifier
|
||||
if msg.blocked != "" {
|
||||
m.result += ", waiting on " + msg.blocked
|
||||
}
|
||||
if msg.assigned {
|
||||
m.result += ", handed to an agent"
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
@@ -188,39 +179,47 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m newTaskModel) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "loading…"
|
||||
return "reading…"
|
||||
}
|
||||
if m.width < minWidth || m.height < minHeight {
|
||||
return 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
|
||||
b.WriteString(titleStyle.Render(truncate("new task · "+m.proj.Name, m.width)))
|
||||
b.WriteString("\n\n")
|
||||
head := titleStyle.Render("Author") + mutedStyle2().Render(" · new task in "+m.proj.Name)
|
||||
|
||||
var body []string
|
||||
body = append(body, "")
|
||||
for i, in := range m.inputs {
|
||||
label := fieldNames[i]
|
||||
if i == fieldTitle {
|
||||
label += " (required)"
|
||||
}
|
||||
muted := mutedStyle
|
||||
if i == m.focus {
|
||||
muted = groupStyle
|
||||
body = append(body, " ▸ "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(label))
|
||||
} else {
|
||||
body = append(body, " "+mutedStyle2().Render(label))
|
||||
}
|
||||
b.WriteString(muted.Render(label))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" " + in.View())
|
||||
b.WriteString("\n")
|
||||
body = append(body, " "+in.View())
|
||||
hint := fieldHints[i]
|
||||
if i == m.focus {
|
||||
body = append(body, " "+dimStyle().Render(hint))
|
||||
}
|
||||
body = append(body, "")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
if m.err != nil {
|
||||
b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
|
||||
body = append(body, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: "+m.err.Error()))
|
||||
} else if m.busy {
|
||||
b.WriteString(mutedStyle.Render("writing to Paperclip…"))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render(truncate("Enter next field, Enter on the last line creates · Esc cancels", m.width)))
|
||||
body = append(body, " "+mutedStyle2().Render("writing to Paperclip…"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return lipgloss.NewStyle().MaxHeight(m.height).Render(b.String())
|
||||
|
||||
p := panel{title: "new task", width: m.width, height: m.height - 2, focused: true}
|
||||
footer := hintLine([]string{
|
||||
keyHint("tab", "next field"),
|
||||
keyHint("enter", "next, and create on the last"),
|
||||
keyHint("esc", "cancel"),
|
||||
}, m.width)
|
||||
|
||||
return strings.Join([]string{head, p.render(body), footer}, "\n")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
40
tui/theme.go
40
tui/theme.go
@@ -80,3 +80,43 @@ func choosePalette(force string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icons. Nerd Font glyphs, with an ASCII fallback so the interface still reads
|
||||
// when the font is not installed or the terminal cannot show it: -ascii, or a
|
||||
// TERM that says it cannot, chooses the fallback.
|
||||
type glyphSet struct {
|
||||
Todo, Running, Blocked, Review, Done, Cancelled string
|
||||
Folder, Branch, Commit, Cost, Person, Agent string
|
||||
Arrow, Dot, Rule string
|
||||
}
|
||||
|
||||
var nerdGlyphs = glyphSet{
|
||||
Todo: "\uf10c", Running: "\uf04b", Blocked: "\uf05e", Review: "\uf06e",
|
||||
Done: "\uf00c", Cancelled: "\uf00d",
|
||||
Folder: "\uf07b", Branch: "\uf126", Commit: "\uf1d3", Cost: "\uf155",
|
||||
Person: "\uf007", Agent: "\uf085",
|
||||
Arrow: "\uf105", Dot: "\uf111", Rule: "─",
|
||||
}
|
||||
|
||||
var plainGlyphs = glyphSet{
|
||||
Todo: "·", Running: "▶", Blocked: "■", Review: "◆",
|
||||
Done: "✓", Cancelled: "✕",
|
||||
Folder: "/", Branch: "branch", Commit: "commit", Cost: "cents",
|
||||
Person: "you", Agent: "agent",
|
||||
Arrow: "›", Dot: "·", Rule: "─",
|
||||
}
|
||||
|
||||
// icons starts as plain Unicode on purpose. The Nerd Font is installed, but a
|
||||
// private-use glyph does not survive TUIOS's own screen capture, so whether it
|
||||
// draws cannot be checked from here. The words next to every icon carry the
|
||||
// meaning either way, and -nerd opts into the richer set once it has been seen
|
||||
// on the terminal that matters.
|
||||
var icons = plainGlyphs
|
||||
|
||||
func chooseGlyphs(useNerd bool) {
|
||||
if useNerd {
|
||||
icons = nerdGlyphs
|
||||
return
|
||||
}
|
||||
icons = plainGlyphs
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user