Files
paperclip-harness/tui/main.go

199 lines
5.1 KiB
Go

// Command harness is the terminal front end for the Paperclip coding factory.
//
// One screen per run. Each screen is meant to run in its own TUIOS pane, on a
// workspace named after it; the layout script in bin/ opens and adopts them.
// This file only wires flags. The screens live in model.go and reply.go.
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"paperclip-harness/tui/paperclip"
)
// defaultAgent is Builder, the agent this project assigns work to.
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
func main() {
screen := flag.String("screen", "board", "which screen to run: board, work, reply, new, agents, project, repair")
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")
ascii := flag.Bool("ascii", false, "plain characters instead of Nerd Font icons")
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(*ascii)
client, err := paperclip.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if !client.UsingUserKey() {
fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused")
}
var m tea.Model
switch *screen {
case "board":
proj, err := client.ResolveProject(*project)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newBoardModel(client, proj, dir)
case "agents":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newAgentsModel(client, dir)
case "project":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newProjectModel(client, dir)
case "repair":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
m = newRepairModel(client, dir)
case "work":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
// Pass the flag, not a resolved task: an empty flag means "follow the
// task chosen on the Board", and resolving it here would pin the pane.
m = newWorkModel(client, dir, *issue)
case "tree":
proj, err := client.ResolveProject(*project)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if *dump {
if err := dumpTree(client, proj); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
m = newModel(client, proj, *agent)
case "new":
proj, err := client.ResolveProject(*project)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m = newTaskForm(client, proj, *agent)
case "log":
task, err := resolveIssue(client, *issue)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m = newLogModel(client, task.ID)
case "reply":
dir := *folder
if dir == "" {
dir, _ = os.Getwd()
}
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 board, work, reply, new, agents, project or repair\n", *screen)
os.Exit(1)
}
// Alternate screen: TUIOS keeps its own scrollback, and a full-screen
// program that redraws in place must not fight it.
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// resolveIssue accepts a task id or an identifier such as AUD-160.
func resolveIssue(c *paperclip.Client, ref string) (paperclip.Issue, error) {
if ref == "" {
return paperclip.Issue{}, fmt.Errorf("no task given: use -issue AUD-160 or a task id")
}
if len(ref) == 36 { // looks like a uuid; ask directly
if is, err := c.Issue(ref); err == nil && is.ID != "" {
return is, nil
}
}
issues, err := c.Issues()
if err != nil {
return paperclip.Issue{}, err
}
for _, is := range issues {
if is.Identifier == ref || is.ID == ref {
return is, nil
}
}
return paperclip.Issue{}, fmt.Errorf("no task matches %q", ref)
}
// dumpTree prints the tree without starting the interface. It is how the data
// path is checked from a script or over ssh.
func dumpTree(c *paperclip.Client, proj paperclip.Project) error {
rows, err := loadRows(c, proj)
if err != nil {
return err
}
fmt.Printf("%s (%s)\n\n", proj.Name, proj.Status)
for _, g := range groupOrder {
any := false
for _, r := range rows {
if r.Group != g {
continue
}
if !any {
fmt.Printf("%s\n", g)
any = true
}
fmt.Printf(" %-8s %-12s %-14s %s\n", r.Issue.Identifier, r.Issue.Status, r.Who, r.Issue.Title)
if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 {
fmt.Printf(" blocked by %v\n", r.Blocker.BlockingIdentities)
}
}
if any {
fmt.Println()
}
}
return nil
}