Work>review and tool panes; panes re-sync their size; the Work pane follows the Board again

This commit is contained in:
Sam
2026-11-10 18:06:07 +11:00
parent ec7e907b2e
commit 4a23f05d20
18 changed files with 884 additions and 25 deletions

View File

@@ -84,7 +84,7 @@ func newAgentsModel(c *paperclip.Client, folder string) agentsModel {
}
func (m agentsModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
return tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
}
func (m agentsModel) load() tea.Cmd {

View File

@@ -49,6 +49,8 @@ type boardModel struct {
activity []paperclip.Activity
needsTab int
tools bool
toolAt int
zones *zone.Manager
filter string
typing bool
@@ -71,7 +73,7 @@ func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) b
}
func (m boardModel) Init() tea.Cmd {
return tea.Batch(m.reload(), tickEvery(5*time.Second))
return tea.Batch(m.reload(), tickEvery(5*time.Second), tea.WindowSize())
}
func (m boardModel) reload() tea.Cmd {
@@ -157,6 +159,27 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyMsg:
if m.tools {
switch msg.String() {
case "esc", "q", "t":
m.tools = false
return m, nil
case "up", "k":
m.toolAt = max(0, m.toolAt-1)
return m, nil
case "down", "j":
m.toolAt = min(len(tools)-1, m.toolAt+1)
return m, nil
case "enter":
t := tools[m.toolAt]
m.tools = false
if err := openTool(m.folder, t.name); err != nil {
m.err = err
}
return m, nil
}
return m, nil
}
if m.showHelp {
m.showHelp = false
return m, nil
@@ -192,6 +215,9 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "?":
m.showHelp = true
return m, nil
case "t":
m.tools = true
return m, nil
case "/":
m.typing = true
return m, nil
@@ -424,6 +450,7 @@ func (m boardModel) footerLine() string {
keyHint("enter", "show it everywhere"),
keyHint("a", "assign"),
keyHint("u", "unblock"),
keyHint("t", "tools"),
keyHint("/", "filter"),
keyHint("?", "keys"),
keyHint("tab", "next panel"),

View File

@@ -80,11 +80,9 @@ func main() {
if dir == "" {
dir, _ = os.Getwd()
}
issue := *issue
if issue == "" {
issue = watchedTask(dir)
}
m = newWorkModel(client, dir, issue)
// 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)

View File

@@ -60,7 +60,7 @@ func newProjectModel(c *paperclip.Client, folder string) projectModel {
}
func (m projectModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(10*time.Second))
return tea.Batch(m.load(), tickEvery(10*time.Second), tea.WindowSize())
}
func (m projectModel) load() tea.Cmd {

View File

@@ -63,7 +63,7 @@ func newRepairModel(c *paperclip.Client, folder string) repairModel {
}
func (m repairModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(8*time.Second))
return tea.Batch(m.load(), tickEvery(8*time.Second), tea.WindowSize())
}
func (m repairModel) load() tea.Cmd {
@@ -194,7 +194,7 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tickMsg:
return m, tea.Batch(m.load(), tickEvery(8*time.Second))
return m, tea.Batch(m.load(), tickEvery(8*time.Second), tea.WindowSize())
case tea.KeyMsg:
if m.showHelp {

View File

@@ -55,7 +55,7 @@ func newReplyModel(c *paperclip.Client, folder, issueID string) replyModel {
}
func (m replyModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
return tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
}
func (m replyModel) load() tea.Cmd {

180
tui/toolpane.go Normal file
View File

@@ -0,0 +1,180 @@
package main
// Tool panes: any terminal program this interface does not cover, opened beside
// what you were reading and closed when you are done with it.
//
// Nothing is rebuilt. Lazygit, Hunk, Yazi, Neovim, fzf and ocr are the tools
// people already use, and a pane is the correct place for them: they keep their
// own keys, their own colours and their own way of working.
//
// A tool pane is not part of the layout. It is created on demand in the
// workspace you are in and it disappears when the program exits, so the eight
// workspaces stay as they are.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
type tool struct {
name string
command string
about string
}
// tools are offered in this order: the shell first because it can reach
// anything, then the three that read code, then the review.
var tools = []tool{
{"shell", "exec zsh -i", "a prompt in the project folder"},
{"lazygit", "lazygit", "branches, commits, staging, history"},
{"hunk", "hunk", "a change read as a review"},
{"yazi", "yazi", "files"},
{"nvim", "nvim", "editing one file"},
{"fzf", "fzf", "picking anything"},
{"ocr", "ocr review", "the review of the current change, in the terminal"},
}
// currentWorkspace finds the workspace this program is drawn in. TUIOS knows;
// the environment names the pane, and the pane's window says which workspace.
func currentWorkspace() (string, string, error) {
session := os.Getenv("TUIOS_SESSION")
pane := os.Getenv("TUIOS_PANE_ID")
if session == "" || pane == "" {
return "", "", fmt.Errorf("this is not running inside a TUIOS pane")
}
out, err := exec.Command("tuios", "list-windows", "-s", session, "--json").Output()
if err != nil {
return "", "", fmt.Errorf("cannot ask TUIOS where this pane is: %w", err)
}
ws := workspaceOf(string(out), pane)
if ws == "" {
return "", "", fmt.Errorf("this pane is not in the layout TUIOS reports")
}
return session, ws, nil
}
// workspaceOf finds the workspace number of one window in the JSON TUIOS
// prints. It decodes properly rather than scanning text: a pane id appears in
// more than one place, and the first match is not always the window.
func workspaceOf(raw, paneID string) string {
var doc struct {
Windows []struct {
WindowID string `json:"window_id"`
Workspace int `json:"workspace"`
} `json:"windows"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return ""
}
for _, w := range doc.Windows {
if w.WindowID == paneID {
return strconv.Itoa(w.Workspace)
}
}
return ""
}
// openTool starts a program as a new pane in the current workspace.
func openTool(folder, name string) error {
var chosen *tool
for i := range tools {
if tools[i].name == name {
chosen = &tools[i]
}
}
if chosen == nil {
return fmt.Errorf("no tool called %q", name)
}
session, ws, err := currentWorkspace()
if err != nil {
return err
}
log, _ := os.Create(filepath.Join(os.TempDir(), "paperclip-harness-tool.log"))
cmd := exec.Command("tuios", "new-window", chosen.name, "-s", session,
"--workspace", ws, "--cwd", folder, "--", "/bin/sh", "-c", chosen.command)
cmd.Stdout = log
cmd.Stderr = log
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("cannot open %s: %w", chosen.name, err)
}
go func() {
cmd.Wait()
if log != nil {
log.Close()
}
}()
return nil
}
// --- the smallest JSON reader that does this one job -------------------------
type scanner struct {
s string
pos int
}
func newScanner(s string) *scanner { return &scanner{s: s} }
// nextObject returns the next {...} block, or "" at the end.
func (sc *scanner) nextObject() string {
for sc.pos < len(sc.s) && sc.s[sc.pos] != '{' {
sc.pos++
}
if sc.pos >= len(sc.s) {
return ""
}
depth := 0
start := sc.pos
for sc.pos < len(sc.s) {
switch sc.s[sc.pos] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
sc.pos++
return sc.s[start:sc.pos]
}
}
sc.pos++
}
return ""
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// fieldNumber reads "name": 12 out of an object.
func fieldNumber(obj, name string) string {
key := `"` + name + `":`
for i := 0; i+len(key) <= len(obj); i++ {
if obj[i:i+len(key)] != key {
continue
}
j := i + len(key)
for j < len(obj) && (obj[j] == ' ' || obj[j] == '"') {
j++
}
start := j
for j < len(obj) && obj[j] >= '0' && obj[j] <= '9' {
j++
}
if _, err := strconv.Atoi(obj[start:j]); err == nil {
return obj[start:j]
}
}
return ""
}

View File

@@ -44,20 +44,22 @@ type workModel struct {
tab int
scroll int
tools bool
toolAt int
readAt time.Time
showHelp bool
width, height int
}
var workTabs = []string{"runs", "change", "facts"}
var workTabs = []string{"runs", "change", "facts", "review"}
func newWorkModel(c *paperclip.Client, folder, issueID string) workModel {
return workModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
}
func (m workModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
return tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
}
func (m workModel) load() tea.Cmd {
@@ -120,14 +122,38 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tickMsg:
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
return m, tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
case tea.KeyMsg:
if m.tools {
switch msg.String() {
case "esc", "q", "t":
m.tools = false
return m, nil
case "up", "k":
m.toolAt = max(0, m.toolAt-1)
return m, nil
case "down", "j":
m.toolAt = min(len(tools)-1, m.toolAt+1)
return m, nil
case "enter":
t := tools[m.toolAt]
m.tools = false
if err := openTool(m.folder, t.name); err != nil {
m.err = err
}
return m, nil
}
return m, nil
}
if m.showHelp {
m.showHelp = false
return m, nil
}
switch msg.String() {
case "t":
m.tools = true
return m, nil
case "?":
m.showHelp = true
return m, nil
@@ -225,10 +251,24 @@ func (m workModel) View() string {
body = m.runsLines(m.width - 4)
case 1:
body = m.diffLines(m.width - 4)
case 3:
body = m.reviewLines(m.width - 4)
default:
body = m.factsLines(m.width - 4)
}
if m.tools {
entries := make([][2]string, 0, len(tools))
for i, t := range tools {
mark := " "
if i == m.toolAt {
mark = "▸ "
}
entries = append(entries, [2]string{mark + t.name, t.about})
}
return helpOverlay("open a tool pane here", entries, m.width, m.height)
}
p := panel{
title: "the work", width: m.width, height: m.height - 2,
tabs: workTabs, active: m.tab, focused: true,
@@ -249,6 +289,7 @@ func (m workModel) View() string {
footer := hintLine([]string{
keyHint("[ ]", "switch tab"),
keyHint("↑↓ or j k", "scroll"),
keyHint("t", "open a tool pane"),
keyHint("r", "read again"),
keyHint("R", "rebuild every pane"),
keyHint("q", "leave"),
@@ -355,6 +396,45 @@ func (m workModel) diffLines(width int) []string {
return out
}
// reviewLines shows the verdicts already recorded on the task. A review is
// 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 m.task.Identifier == "" {
return append(out, mutedStyle2().Render(" no task chosen"))
}
cs, err := m.client.Comments(m.task.ID)
if err != nil {
return append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(" "+err.Error()))
}
found := 0
for i := len(cs) - 1; i >= 0 && found < 3; i-- {
body := cs[i].Body
if !strings.HasPrefix(body, "## Review") &&
!strings.HasPrefix(body, "## Merge refused") &&
!strings.HasPrefix(body, "## Merged") &&
!strings.HasPrefix(body, "## Merge undone") {
continue
}
found++
blocking := strings.Contains(body, "critical") || strings.Contains(body, "**high/")
colour := active.Success
if strings.HasPrefix(body, "## Merge refused") || blocking {
colour = active.Error
}
for _, ln := range wrap(strings.ReplaceAll(body, "\n", " · "), width-2) {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(ln))
}
out = append(out, "")
}
if found == 0 {
out = append(out, mutedStyle2().Render(" no review recorded for this task yet"))
out = append(out, "")
out = append(out, dimStyle().Render(" run one with: bin/review "+m.task.Identifier+" --post"))
}
return out
}
func (m workModel) factsLines(width int) []string {
var out []string
out = append(out, "")