Cleanup pane, Pi session tools, review from the UI, project registry, needs panel acts

This commit is contained in:
Sam
2026-11-11 14:16:12 +11:00
parent 0476afcb95
commit 3ec960e349
16 changed files with 726 additions and 18 deletions

View File

@@ -381,13 +381,13 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
return m, tea.Quit
case "[", "h":
case "(", "[", "h":
if m.tab > 0 {
m.tab--
m.scroll = 0
}
return m, nil
case "]", "l", "tab":
case ")", "]", "l", "tab":
if m.tab < len(agentTabs)-1 {
m.tab++
m.scroll = 0
@@ -853,7 +853,7 @@ func (m agentsModel) View() string {
to := min(len(body), from+visible)
hints := []string{
keyHint("[ ]", "tab"),
keyHint("( )", "tab"),
keyHint("enter", "open"),
keyHint("p P w", "pause/resume/wake"),
keyHint("r", "refresh"),
@@ -861,7 +861,7 @@ func (m agentsModel) View() string {
}
if m.tab == 4 || m.tab == 5 {
hints = []string{
keyHint("[ ]", "tab"),
keyHint("( )", "tab"),
keyHint("space", "toggle"),
keyHint("↑↓", "move"),
keyHint("r", "refresh"),

View File

@@ -293,9 +293,15 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.notice = "showing " + r.Issue.Identifier + " in every workspace"
// On the needs panel, enter also takes you where the answer is.
if m.focus == focusNeeds {
if err := switchWorkspace("Reply"); err != nil {
m.err = err
}
}
}
return m, nil
case "[", "h":
case "(", "[", "h":
if m.focus == focusNeeds && m.needsTab > 0 {
m.needsTab--
}
@@ -485,7 +491,7 @@ func (m boardModel) footerLine() string {
default:
return hintLine([]string{
keyHint("tab", "panel"),
keyHint("[ ]", "tab"),
keyHint("( )", "tab"),
keyHint("enter", "show this task everywhere"),
keyHint("r", "refresh"),
keyHint("q", "quit"),

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -55,7 +56,7 @@ type projectModel struct {
width, height int
}
var projectTabs = []string{"overview", "facts", "costs", "settings"}
var projectTabs = []string{"overview", "facts", "costs", "settings", "projects"}
func newProjectModel(c *paperclip.Client, folder string) projectModel {
return projectModel{client: c, folder: folder}
@@ -162,13 +163,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
return m, tea.Quit
case "[", "h":
case "(", "[", "h":
if m.tab > 0 {
m.tab--
m.scroll = 0
}
return m, nil
case "]", "l", "tab":
case ")", "]", "l", "tab":
if m.tab < len(projectTabs)-1 {
m.tab++
m.scroll = 0
@@ -234,6 +235,8 @@ func (m projectModel) View() string {
body = m.factLines()
case 2:
body = m.costLines()
case 4:
body = m.projectListLines(m.width - 4)
default:
body = m.settingLines()
}
@@ -254,7 +257,7 @@ func (m projectModel) View() string {
to := min(len(body), from+visible)
footer := hintLine([]string{
keyHint("[ ]", "tab"),
keyHint("( )", "tab"),
keyHint("↑↓", "scroll"),
keyHint("r", "refresh"),
keyHint("R", "rebuild"),
@@ -433,3 +436,70 @@ func (m projectModel) verifyCommand() string {
}
var _ = exec.Command
// registryRow is one adopted project, from the file bin/office writes.
type registryRow struct {
name, folder, host, session, id string
}
// readRegistry lists adopted projects. It is the only thing this program keeps
// besides the project file: where a folder is, and on which machine.
func readRegistry() []registryRow {
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".config", "paperclip-harness", "projects.toml")
b, err := os.ReadFile(path)
if err != nil {
return nil
}
var rows []registryRow
cur := map[string]string{}
id := ""
flush := func() {
if id != "" && cur["folder"] != "" {
rows = append(rows, registryRow{
name: cur["name"], folder: cur["folder"], host: cur["host"],
session: cur["session"], id: id,
})
}
cur = map[string]string{}
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[projects.") {
flush()
id = strings.Trim(strings.TrimPrefix(line, "[projects."), "]'\"")
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
cur[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
}
flush()
return rows
}
// projectListLines shows every adopted project and where it lives.
func (m projectModel) projectListLines(width int) []string {
rows := readRegistry()
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-26s %-12s %-14s %s", "project", "machine", "session", "folder"))}
me, _ := os.Hostname()
for _, r := range rows {
here := ""
if r.host == me || r.host == strings.Split(me, ".")[0] {
here = " (this machine)"
}
line := fmt.Sprintf(" %-26s %-12s %-14s %s%s",
truncateVisible(r.name, 26), r.host, truncateVisible(r.session, 14),
truncateVisible(r.folder, max(10, width-60)), here)
out = append(out, line)
}
if len(rows) == 0 {
out = append(out, " "+mutedStyle2().Render("none adopted yet: bin/office <folder> adds one"))
}
out = append(out, "")
out = append(out, dimStyle().Render(" a project on another machine can be listed here but not opened from this one."))
out = append(out, dimStyle().Render(" to open one: attach to its session, or run bin/office <folder> there."))
return out
}

View File

@@ -140,3 +140,52 @@ func mergeLogTail(lines int) []string {
start := max(0, len(all)-lines)
return all[start:]
}
// reviewLogPath is where a review started from the interface writes its output.
func reviewLogPath() string {
return filepath.Join(os.TempDir(), "paperclip-harness-review.log")
}
// startReview runs bin/review for one task and returns at once. The Review tab
// follows the log, so a review takes no keypress more than starting it.
func startReview(folder, identifier string) error {
review := filepath.Join(exeDir(), "review")
if _, err := os.Stat(review); err != nil {
return fmt.Errorf("no bin/review next to the program (%s)", review)
}
log, err := os.Create(reviewLogPath())
if err != nil {
return err
}
fmt.Fprintf(log, "review of %s started %s\n", identifier, time.Now().Format("15:04:05"))
cmd := exec.Command(review, identifier, "--post")
cmd.Dir = folder
cmd.Stdout = log
cmd.Stderr = log
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
log.Close()
return err
}
go func() {
err := cmd.Wait()
if err != nil {
fmt.Fprintf(log, "\nreview stopped: %v\n", err)
} else {
fmt.Fprintf(log, "\nreview finished %s\n", time.Now().Format("15:04:05"))
}
log.Close()
}()
return nil
}
func reviewLogTail(lines int) []string {
b, err := os.ReadFile(reviewLogPath())
if err != nil {
return []string{"", " no review has been run from this screen yet", "", " press v to review this task"}
}
all := splitLines(string(b))
start := max(0, len(all)-lines)
return all[start:]
}

View File

@@ -7,10 +7,12 @@ package main
// pass, and a screen that says otherwise is how an afternoon disappears.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
@@ -27,12 +29,22 @@ type checkResult struct {
}
type repairMsg struct {
sessions []sessionRow
checks []checkResult
workspaces []layoutRow
runs int
err error
}
// sessionRow is one TUIOS session as the cleanup tab sees it.
type sessionRow struct {
name string
panes int
lastUsed time.Time
attached bool
protected bool // this project's own session, or nothing to clean
}
type layoutRow struct {
number int
name string
@@ -54,9 +66,15 @@ type repairModel struct {
showHelp bool
width, height int
sessions []sessionRow
sessAt int
ticked map[string]bool
confirmKill bool
killNote string
}
var repairTabs = []string{"health", "layout", "rebuild log"}
var repairTabs = []string{"health", "layout", "rebuild log", "cleanup"}
func newRepairModel(c *paperclip.Client, folder string) repairModel {
return repairModel{client: c, folder: folder}
@@ -120,7 +138,8 @@ func (m repairModel) load() tea.Cmd {
}
ws, layoutErr := layoutRows(session)
res := repairMsg{checks: checks, workspaces: ws}
sess := listSessions(session)
res := repairMsg{checks: checks, workspaces: ws, sessions: sess}
if layoutErr != nil {
res.err = layoutErr
}
@@ -197,6 +216,30 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.load(), tickEvery(8*time.Second), tea.WindowSize())
case tea.KeyMsg:
if m.confirmKill {
switch msg.String() {
case "y":
m.confirmKill = false
killed := 0
for _, row := range m.sessions {
if !m.ticked[row.name] || row.protected || row.attached {
continue
}
out, err := exec.Command("tuios", "kill-session", row.name).CombinedOutput()
if err != nil {
m.err = fmt.Errorf("could not kill %q: %v %s", row.name, err, strings.TrimSpace(string(out)))
continue
}
killed++
}
m.ticked = map[string]bool{}
m.killNote = fmt.Sprintf("%d session(s) closed", killed)
return m, m.load()
case "esc", "n", "q":
m.confirmKill = false
}
return m, nil
}
if m.showHelp {
m.showHelp = false
return m, nil
@@ -226,14 +269,33 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = nil
m.tab = 1
return m, m.load()
case "[", "h":
case "(", "[", "h":
m.tab = (m.tab + len(repairTabs) - 1) % len(repairTabs)
m.scroll = 0
return m, nil
case "]", "l", "tab":
case ")", "]", "l", "tab":
m.tab = (m.tab + 1) % len(repairTabs)
m.scroll = 0
return m, nil
case " ":
if m.tab == 3 && len(m.sessions) > 0 {
row := m.sessions[max(0, min(m.sessAt, len(m.sessions)-1))]
if row.protected || row.attached {
m.killNote = row.name + " is attached or is this project's session; it is not touched"
return m, nil
}
if m.ticked == nil {
m.ticked = map[string]bool{}
}
m.ticked[row.name] = !m.ticked[row.name]
return m, nil
}
return m, nil
case "x":
if m.tab == 3 && len(m.ticked) > 0 {
m.confirmKill = true
}
return m, nil
case "up", "k":
m.scroll--
return m, nil
@@ -245,6 +307,9 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case repairMsg:
if msg.sessions != nil {
m.sessions = msg.sessions
}
m.checks = msg.checks
m.workspaces = msg.workspaces
m.err = msg.err
@@ -287,6 +352,8 @@ func (m repairModel) View() string {
body = m.layoutLines()
case 2:
body = m.logLines()
case 3:
body = m.cleanupLines(m.width - 3)
}
p := panel{title: "checks", width: m.width, height: m.height - 2, tabs: repairTabs, active: m.tab, focused: true}
@@ -302,7 +369,7 @@ func (m repairModel) View() string {
to := min(len(body), from+visible)
footer := hintLine([]string{
keyHint("[ ]", "tab"),
keyHint("( )", "tab"),
keyHint("R", "rebuild"),
keyHint("↑↓", "scroll"),
keyHint("r", "recheck"),
@@ -378,3 +445,83 @@ func (m repairModel) layoutLines() []string {
out = append(out, dimStyle().Render(" so every panel is found by name, never by number."))
return out
}
// listSessions reads every TUIOS session on this machine, newest activity first.
// It never reads or touches the Pi conversation files: those are separate and
// are the person's history.
func listSessions(current string) []sessionRow {
out, err := exec.Command("tuios", "ls", "--all-hosts", "--json").Output()
if err != nil {
return nil
}
var doc struct {
Hosts []struct {
Host string `json:"host"`
Sessions []struct {
Name string `json:"name"`
Windows int `json:"window_count"`
LastActive int64 `json:"last_active"`
Attached bool `json:"attached"`
} `json:"sessions"`
} `json:"hosts"`
}
if json.Unmarshal(out, &doc) != nil {
return nil
}
var rows []sessionRow
for _, host := range doc.Hosts {
for _, s := range host.Sessions {
// A session on another machine is listed but never touched from here.
row := sessionRow{
name: s.Name,
panes: s.Windows,
lastUsed: time.Unix(s.LastActive, 0),
attached: s.Attached || host.Host != "local",
}
if s.Name == current {
row.protected = true
}
rows = append(rows, row)
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].lastUsed.After(rows[j].lastUsed) })
return rows
}
// cleanupLines lists TUIOS sessions, marks what is protected, and lets a person
// tick the ones to close. It never touches ~/.pi/paperclips: closing a TUIOS
// session ends panes, and the Pi conversations are files that stay.
func (m repairModel) cleanupLines(width int) []string {
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-34s %6s %-12s %s", "session", "panes", "last used", "state"))}
for i, r := range m.sessions {
state := ""
switch {
case r.protected:
state = "this project's session"
case r.attached:
state = "attached, or on another machine"
case m.ticked[r.name]:
state = "ticked for closing"
}
line := fmt.Sprintf(" %-34s %6d %-12s %s", truncateVisible(r.name, 34), r.panes,
r.lastUsed.Format("2 Jan 15:04"), state)
mark := " "
if m.ticked[r.name] {
mark = "▸ "
}
if i == m.sessAt {
out = append(out, selectedStyle2().Render(padTo(mark+line, width)))
continue
}
out = append(out, mark+line)
}
out = append(out, "")
if m.killNote != "" {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(m.killNote))
out = append(out, "")
}
out = append(out, dimStyle().Render(" space ticks a session, x closes the ticked ones after asking."))
out = append(out, dimStyle().Render(" closing a TUIOS session ends its panes. It does not delete anything in"))
out = append(out, dimStyle().Render(" ~/.pi/paperclips, where every Pi conversation is kept."))
return out
}

View File

@@ -39,6 +39,8 @@ var tools = []tool{
// A review that finds nothing exits in a second, which looks like a broken
// pane. It is given a last line so the output can be read before closing.
{"ocr", "ocr review --color never; printf '\\npress enter to close\\n'; read -r _", "the review of the current change"},
{"pi sessions", "yazi ~/.pi/paperclips", "every Pi conversation Paperclip has recorded"},
{"pi resume", "pi --resume", "Pi's own list of sessions; pick one and read it"},
}
// currentWorkspace finds the workspace this program is drawn in. TUIOS knows;
@@ -185,3 +187,31 @@ func fieldNumber(obj, name string) string {
}
return ""
}
// switchWorkspace shows a workspace by name. It is how one pane can hand you to
// another without any message passing: the name is the address.
func switchWorkspace(name string) error {
session, _, err := currentWorkspace()
if err != nil {
return err
}
out, err := exec.Command("tuios", "list-workspaces", "-s", session, "--json").Output()
if err != nil {
return err
}
var doc struct {
Workspaces []struct {
Workspace int `json:"workspace"`
Name string `json:"name"`
} `json:"workspaces"`
}
if err := json.Unmarshal(out, &doc); err != nil {
return err
}
for _, w := range doc.Workspaces {
if w.Name == name {
return exec.Command("tuios", "select-workspace", strconv.Itoa(w.Workspace), "-s", session).Run()
}
}
return fmt.Errorf("no workspace called %q", name)
}

View File

@@ -294,6 +294,18 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return pickTasksMsg{issues: mine}
}
case "v":
// A review is cheap to start and posts its verdict on the task, so
// the record lives with the work.
if m.task.Identifier == "" {
return m, nil
}
if err := startReview(m.folder, m.task.Identifier); err != nil {
m.err = err
return m, nil
}
m.tab = 3
return m, m.load()
case "m":
// Merging is a decision, so it asks first and shows what it will do.
if m.task.Identifier == "" {
@@ -314,13 +326,13 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
return m, tea.Quit
case "[", "h":
case "(", "[", "h":
if m.tab > 0 {
m.tab--
m.scroll = 0
}
return m, nil
case "]", "l", "tab":
case ")", "]", "l", "tab":
if m.tab < len(workTabs)-1 {
m.tab++
m.scroll = 0
@@ -519,11 +531,12 @@ func (m workModel) View() string {
footer := hintLine([]string{
keyHint("n p", "task"),
keyHint("[ ]", "tab"),
keyHint("( )", "tab"),
keyHint("↑↓", "scroll"),
keyHint("enter", "log"),
keyHint("o", "pick"),
keyHint("m", "merge"),
keyHint("v", "review"),
keyHint("t", "tools"),
keyHint("r", "refresh"),
keyHint("R", "rebuild"),
@@ -639,6 +652,13 @@ func (m workModel) diffLines(width int) []string {
// written as a comment, so the record lives with the work and survives us.
func (m workModel) reviewLines(width int) []string {
out := []string{"", dimStyle().Render(" verdicts recorded on this task")}
if rl := reviewLogTailFor(m.task.Identifier); len(rl) > 0 {
out = append(out, "")
out = append(out, dimStyle().Render(" the last review started from here:"))
for _, ln := range rl {
out = append(out, " "+mutedStyle2().Render(truncateVisible(ln, width-2)))
}
}
if m.task.Identifier == "" {
return append(out, mutedStyle2().Render(" no task chosen"))
}
@@ -776,3 +796,14 @@ func firstLineWith(lines []string, marker string) string {
}
return "none"
}
// reviewLogTailFor returns the last review log, but only when it was for this
// task: another task's review is not evidence about this one.
func reviewLogTailFor(identifier string) []string {
tail := reviewLogTail(8)
joined := strings.Join(tail, "\n")
if !strings.Contains(joined, identifier) {
return nil
}
return tail
}