Work>review and tool panes; panes re-sync their size; the Work pane follows the Board again
This commit is contained in:
@@ -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 {
|
||||
|
||||
29
tui/board.go
29
tui/board.go
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
180
tui/toolpane.go
Normal 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 ""
|
||||
}
|
||||
86
tui/work.go
86
tui/work.go
@@ -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, "")
|
||||
|
||||
Reference in New Issue
Block a user