R rebuilds every pane detached; r refreshes data; Author gains guidance and preview; Repair gains the rebuild log

This commit is contained in:
Sam
2026-11-10 14:34:14 +11:00
parent 351cb35e8e
commit 972d4c0184
15 changed files with 683 additions and 23 deletions

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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
View 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
}

View File

@@ -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")))

View File

@@ -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 {

View File

@@ -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)