R rebuilds every pane detached; r refreshes data; Author gains guidance and preview; Repair gains the rebuild log
This commit is contained in:
@@ -130,6 +130,12 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "r":
|
||||
m.loading = true
|
||||
return m, m.reload()
|
||||
case "R":
|
||||
if err := startRebuild(m.folder); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
@@ -315,6 +321,7 @@ func (m boardModel) footerLine() string {
|
||||
keyHint("a", "assign"),
|
||||
keyHint("tab", "next panel"),
|
||||
keyHint("r", "read again"),
|
||||
keyHint("R", "rebuild every pane"),
|
||||
keyHint("q", "leave"),
|
||||
}, m.width)
|
||||
case focusSummary:
|
||||
|
||||
125
tui/newtask.go
125
tui/newtask.go
@@ -20,10 +20,10 @@ import (
|
||||
)
|
||||
|
||||
type createdMsg struct {
|
||||
issue paperclip.Issue
|
||||
blocked string
|
||||
issue paperclip.Issue
|
||||
blocked string
|
||||
assigned bool
|
||||
err error
|
||||
err error
|
||||
}
|
||||
|
||||
type newTaskModel struct {
|
||||
@@ -37,9 +37,12 @@ type newTaskModel struct {
|
||||
busy bool
|
||||
result string
|
||||
|
||||
tab int
|
||||
width, height int
|
||||
}
|
||||
|
||||
var authorTabs = []string{"form", "guidance", "preview"}
|
||||
|
||||
const (
|
||||
fieldTitle = iota
|
||||
fieldBody
|
||||
@@ -127,7 +130,19 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyCtrlN:
|
||||
m.tab = (m.tab + 1) % len(authorTabs)
|
||||
return m, nil
|
||||
case tea.KeyCtrlP:
|
||||
m.tab = (m.tab + len(authorTabs) - 1) % len(authorTabs)
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
if m.tab != 0 {
|
||||
m.tab = 0
|
||||
return m, m.inputs[m.focus].Focus()
|
||||
}
|
||||
return m, tea.Quit
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
m.inputs[m.focus].Blur()
|
||||
@@ -177,6 +192,54 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// descriptionPreview shows exactly what the agent will be given: the same
|
||||
// text that submit() puts in the task's description, built the same way.
|
||||
func (m newTaskModel) descriptionPreview() string {
|
||||
body := strings.TrimSpace(m.inputs[fieldBody].Value())
|
||||
verify := strings.TrimSpace(m.inputs[fieldVerify].Value())
|
||||
blocker := strings.TrimSpace(m.inputs[fieldBlocker].Value())
|
||||
out := body
|
||||
if verify != "" {
|
||||
out += "\n\nDone when this command succeeds:\n\n " + verify
|
||||
}
|
||||
if blocker != "" {
|
||||
out += "\n\nMust finish first: " + blocker
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return "nothing written yet"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m newTaskModel) previewLines(width int) []string {
|
||||
out := []string{"", dimStyle().Render(" the task's description, exactly as the agent receives it"), ""}
|
||||
for _, para := range strings.Split(m.descriptionPreview(), "\n") {
|
||||
for _, ln := range wrap(para, max(8, width-2)) {
|
||||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(ln))
|
||||
}
|
||||
if para == "" {
|
||||
out = append(out, "")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m newTaskModel) guidanceLines(width int) []string {
|
||||
out := []string{"", groupHeading("what a task needs", 4, width-2), ""}
|
||||
add := func(k, v string) {
|
||||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render(k))
|
||||
for _, ln := range wrap(v, max(8, width-4)) {
|
||||
out = append(out, " "+mutedStyle2().Render(ln))
|
||||
}
|
||||
out = append(out, "")
|
||||
}
|
||||
add("one thing", "A task that asks for two things cannot be judged, only argued about.")
|
||||
add("a command", "The verify command decides when the work is finished. The agent does not.")
|
||||
add("a boundary", "Say which files may change. An unbounded task is how a review becomes an autopsy.")
|
||||
add("a blocker", "If it must wait, name the task it waits for. A sentence in the description is not a blocker.")
|
||||
return out
|
||||
}
|
||||
|
||||
func (m newTaskModel) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "reading…"
|
||||
@@ -187,7 +250,47 @@ func (m newTaskModel) View() string {
|
||||
}
|
||||
|
||||
head := titleStyle.Render("Author") + mutedStyle2().Render(" · new task in "+m.proj.Name)
|
||||
bodyHeight := m.height - 2
|
||||
|
||||
formLines := m.formLines(m.width - 4)
|
||||
footer := hintLine([]string{
|
||||
keyHint("tab", "next field"),
|
||||
keyHint("enter", "next, and create on the last"),
|
||||
keyHint("ctrl+n", "form, guidance, preview"),
|
||||
keyHint("esc", "cancel"),
|
||||
}, m.width)
|
||||
|
||||
// Wide screens: the form on the left, what to write and what will be sent on
|
||||
// the right. Narrow ones: one panel, switched with the tabs.
|
||||
if m.width >= 120 {
|
||||
formW := max(50, m.width*58/100)
|
||||
sideW := m.width - formW - 1
|
||||
topH := bodyHeight / 2
|
||||
bottomH := bodyHeight - topH
|
||||
|
||||
form := panel{title: "new task", width: formW, height: bodyHeight, focused: true}
|
||||
guide := panel{title: "what a task needs", width: sideW, height: topH}
|
||||
prev := panel{title: "what will be sent", width: sideW, height: bottomH}
|
||||
page := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
form.render(formLines), " ",
|
||||
lipgloss.JoinVertical(lipgloss.Left,
|
||||
guide.render(m.guidanceLines(sideW-3)),
|
||||
prev.render(m.previewLines(sideW-3))))
|
||||
return strings.Join([]string{head, page, footer}, "\n")
|
||||
}
|
||||
|
||||
p := panel{title: "new task", width: m.width, height: bodyHeight, tabs: authorTabs, active: m.tab, focused: true}
|
||||
lines := formLines
|
||||
switch m.tab {
|
||||
case 1:
|
||||
lines = m.guidanceLines(m.width - 3)
|
||||
case 2:
|
||||
lines = m.previewLines(m.width - 3)
|
||||
}
|
||||
return strings.Join([]string{head, p.render(lines), footer}, "\n")
|
||||
}
|
||||
|
||||
func (m newTaskModel) formLines(width int) []string {
|
||||
var body []string
|
||||
body = append(body, "")
|
||||
for i, in := range m.inputs {
|
||||
@@ -201,25 +304,15 @@ func (m newTaskModel) View() string {
|
||||
body = append(body, " "+mutedStyle2().Render(label))
|
||||
}
|
||||
body = append(body, " "+in.View())
|
||||
hint := fieldHints[i]
|
||||
if i == m.focus {
|
||||
body = append(body, " "+dimStyle().Render(hint))
|
||||
body = append(body, " "+dimStyle().Render(fieldHints[i]))
|
||||
}
|
||||
body = append(body, "")
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
body = append(body, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: "+m.err.Error()))
|
||||
} else if m.busy {
|
||||
body = append(body, " "+mutedStyle2().Render("writing to Paperclip…"))
|
||||
}
|
||||
|
||||
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")
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -145,6 +145,12 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "R":
|
||||
if err := startRebuild(m.folder); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "[", "h":
|
||||
if m.tab > 0 {
|
||||
m.tab--
|
||||
@@ -226,6 +232,7 @@ func (m projectModel) View() string {
|
||||
keyHint("[ ]", "switch tab"),
|
||||
keyHint("↑↓ or j k", "scroll"),
|
||||
keyHint("r", "read again"),
|
||||
keyHint("R", "rebuild every pane"),
|
||||
keyHint("q", "leave"),
|
||||
}, m.width)
|
||||
|
||||
|
||||
90
tui/rebuild.go
Normal file
90
tui/rebuild.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
// Rebuilding and refreshing, and the difference between them.
|
||||
//
|
||||
// r read the data again in this panel. Nothing is closed, nothing moves.
|
||||
// R rebuild every workspace and pane from the project file: close what the
|
||||
// layout owns, then build it again in order.
|
||||
//
|
||||
// A rebuild must survive the panel that started it, because that panel is one of
|
||||
// the things being replaced. So the rebuild runs detached, in its own session,
|
||||
// with its output in a log the Repair workspace can show.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rebuildLogPath() string {
|
||||
return filepath.Join(os.TempDir(), "paperclip-harness-rebuild.log")
|
||||
}
|
||||
|
||||
// startRebuild launches bin/board --replace for this folder and returns at once.
|
||||
func startRebuild(folder string) error {
|
||||
board := filepath.Join(exeDir(), "board")
|
||||
if _, err := os.Stat(board); err != nil {
|
||||
return fmt.Errorf("no bin/board next to the program (%s)", board)
|
||||
}
|
||||
log, err := os.Create(rebuildLogPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(log, "rebuild started %s\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
cmd := exec.Command(board, "--folder", folder, "--replace")
|
||||
cmd.Stdout = log
|
||||
cmd.Stderr = log
|
||||
cmd.Stdin = nil
|
||||
// Its own session, so closing this pane does not take the rebuild with it.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Close()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
fmt.Fprintf(log, "rebuild finished %s\n", time.Now().Format("15:04:05"))
|
||||
log.Close()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func exeDir() string {
|
||||
p, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(p)
|
||||
}
|
||||
|
||||
// rebuildLogTail returns the end of the last rebuild, for the Repair workspace.
|
||||
func rebuildLogTail(lines int) []string {
|
||||
b, err := os.ReadFile(rebuildLogPath())
|
||||
if err != nil {
|
||||
return []string{"", " no rebuild has been run since this machine started"}
|
||||
}
|
||||
all := splitLines(string(b))
|
||||
start := max(0, len(all)-lines)
|
||||
return all[start:]
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, r := range s {
|
||||
if r == '\n' {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(r)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -55,7 +55,7 @@ type repairModel struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
var repairTabs = []string{"health", "layout"}
|
||||
var repairTabs = []string{"health", "layout", "rebuild log"}
|
||||
|
||||
func newRepairModel(c *paperclip.Client, folder string) repairModel {
|
||||
return repairModel{client: c, folder: folder}
|
||||
@@ -201,7 +201,17 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "[", "h", "]", "l", "tab":
|
||||
case "R":
|
||||
if err := startRebuild(m.folder); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "[", "h":
|
||||
m.tab = (m.tab + len(repairTabs) - 1) % len(repairTabs)
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
case "]", "l", "tab":
|
||||
m.tab = (m.tab + 1) % len(repairTabs)
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
@@ -241,8 +251,11 @@ func (m repairModel) View() string {
|
||||
}
|
||||
|
||||
body := m.healthLines()
|
||||
if m.tab == 1 {
|
||||
switch m.tab {
|
||||
case 1:
|
||||
body = m.layoutLines()
|
||||
case 2:
|
||||
body = m.logLines()
|
||||
}
|
||||
|
||||
p := panel{title: "checks", width: m.width, height: m.height - 2, tabs: repairTabs, active: m.tab, focused: true}
|
||||
@@ -259,6 +272,7 @@ func (m repairModel) View() string {
|
||||
|
||||
footer := hintLine([]string{
|
||||
keyHint("[ ]", "switch tab"),
|
||||
keyHint("R", "rebuild every pane"),
|
||||
keyHint("↑↓ or j k", "scroll"),
|
||||
keyHint("r", "check again"),
|
||||
keyHint("q", "leave"),
|
||||
@@ -303,6 +317,18 @@ func (m repairModel) healthLines() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// logLines shows what the last rebuild did. It is read from the log the rebuild
|
||||
// writes, so the record of a repair survives the panes it replaced.
|
||||
func (m repairModel) logLines() []string {
|
||||
out := []string{"", dimStyle().Render(" " + rebuildLogPath()), ""}
|
||||
for _, ln := range rebuildLogTail(m.height - 6) {
|
||||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(ln))
|
||||
}
|
||||
out = append(out, "")
|
||||
out = append(out, dimStyle().Render(" press R on any screen to rebuild every pane"))
|
||||
return out
|
||||
}
|
||||
|
||||
func (m repairModel) layoutLines() []string {
|
||||
out := []string{""}
|
||||
out = append(out, dimStyle().Render(fmt.Sprintf(" %-9s %-14s %s", "workspace", "name", "panes")))
|
||||
|
||||
11
tui/reply.go
11
tui/reply.go
@@ -113,7 +113,7 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !m.inBox {
|
||||
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
|
||||
}
|
||||
return m, tickEvery(5*time.Second)
|
||||
return m, tickEvery(5 * time.Second)
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.inBox {
|
||||
@@ -178,6 +178,12 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "R":
|
||||
if err := startRebuild(m.folder); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
m.notice = ""
|
||||
return m, nil
|
||||
@@ -257,13 +263,14 @@ func (m replyModel) View() string {
|
||||
title: fmt.Sprintf("reply · %s", statusNote),
|
||||
width: m.width, height: composerHeight, focused: m.inBox,
|
||||
}
|
||||
boxLines := m.composerLines(m.width - 4, composer.bodyHeight())
|
||||
boxLines := m.composerLines(m.width-4, composer.bodyHeight())
|
||||
|
||||
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("R", "rebuild every pane"),
|
||||
keyHint("q", "leave"),
|
||||
}, m.width)
|
||||
if m.inBox {
|
||||
|
||||
@@ -127,6 +127,12 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "R":
|
||||
if err := startRebuild(m.folder); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "[", "h":
|
||||
if m.tab > 0 {
|
||||
m.tab--
|
||||
@@ -224,6 +230,7 @@ func (m workModel) View() string {
|
||||
keyHint("[ ]", "switch tab"),
|
||||
keyHint("↑↓ or j k", "scroll"),
|
||||
keyHint("r", "read again"),
|
||||
keyHint("R", "rebuild every pane"),
|
||||
keyHint("q", "leave"),
|
||||
}, m.width)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user