Merge asks first; Work>work lists runs with a log tab; Shell says where everything is

This commit is contained in:
Sam
2026-11-11 12:58:37 +11:00
parent 7198c34e42
commit 049e25af70
14 changed files with 697 additions and 22 deletions

View File

@@ -19,6 +19,12 @@ import (
"paperclip-harness/tui/paperclip"
)
type runLogMsg2 struct {
runID string
text string
err error
}
type pickTasksMsg struct {
issues []paperclip.Issue
err error
@@ -46,11 +52,15 @@ type workModel struct {
diff string
diffErr error
err error
logRunID string
logText string
tab int
scroll int
tools bool
toolAt int
tab int
scroll int
runAt int
confirm bool
tools bool
toolAt int
// choosing a task here, so the screen does not depend on the Board
picking bool
@@ -63,7 +73,7 @@ type workModel struct {
width, height int
}
var workTabs = []string{"runs", "change", "facts", "review", "merge"}
var workTabs = []string{"runs", "change", "facts", "review", "log", "merge"}
func newWorkModel(c *paperclip.Client, folder, issueID string) workModel {
return workModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
@@ -73,6 +83,15 @@ func (m workModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
}
// loadRunLog reads one run's output for the log tab.
func (m workModel) loadRunLog(runID string) tea.Cmd {
c := m.client
return func() tea.Msg {
text, err := c.RunLog(runID, 8<<20)
return runLogMsg2{runID: runID, text: text, err: err}
}
}
func (m workModel) load() tea.Cmd {
c, folder := m.client, m.folder
fixed, explicit := m.issue, m.explicit
@@ -136,6 +155,22 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.load(), tickEvery(5*time.Second), tea.WindowSize())
case tea.KeyMsg:
if m.confirm {
switch msg.String() {
case "y":
m.confirm = false
if err := startMerge(m.folder, m.task.Identifier); err != nil {
m.err = err
return m, nil
}
m.tab = 5
m.scroll = 1 << 30
return m, m.load()
case "esc", "n", "q":
m.confirm = false
}
return m, nil
}
if m.picking {
switch msg.String() {
case "esc", "q", "o":
@@ -220,18 +255,12 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return pickTasksMsg{issues: mine}
}
case "m":
// Merging is a decision, so it asks first and shows what it will do.
if m.task.Identifier == "" {
return m, nil
}
// The merge runs on its own and writes what it does to a log this
// tab reads, so nobody has to type a command or leave the screen.
if err := startMerge(m.folder, m.task.Identifier); err != nil {
m.err = err
return m, nil
}
m.tab = 4
m.scroll = 1 << 30
return m, m.load()
m.confirm = true
return m, nil
case "?":
m.showHelp = true
return m, nil
@@ -258,9 +287,15 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case "up", "k":
if m.tab == 0 && m.runAt > 0 {
m.runAt--
}
m.scroll--
return m, nil
case "down", "j":
if m.tab == 0 && m.runAt < len(m.runs)-1 {
m.runAt++
}
m.scroll++
return m, nil
case "g":
@@ -269,10 +304,25 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "G":
m.scroll = 1 << 30
return m, nil
case "enter":
if m.tab == 0 && m.runAt < len(m.runs) {
m.tab = 4
m.scroll = 1 << 30
return m, m.loadRunLog(m.runs[m.runAt].ID)
}
return m, nil
case "esc":
return m, nil
}
case runLogMsg2:
m.logRunID = msg.runID
m.logText = msg.text
if msg.err != nil {
m.err = msg.err
}
return m, nil
case pickTasksMsg:
m.pickList = msg.issues
m.pickAt = 0
@@ -341,11 +391,27 @@ func (m workModel) View() string {
case 3:
body = m.reviewLines(m.width - 4)
case 4:
body = m.logLinesWork(m.width - 4)
case 5:
body = m.mergeLines(m.width - 4)
default:
body = m.factsLines(m.width - 4)
}
if m.confirm {
verify := "no verify command: the review decides"
if cmd := verifyFromDescription(m.task.Description); cmd != "" {
verify = "verify command: " + cmd
}
return helpOverlay("merge "+m.task.Identifier+"?", [][2]string{
{"what happens", "rebase onto the base branch"},
{"", verify},
{"", "ocr reviews the change; a critical finding refuses the merge"},
{"", "merge, then re-run the checks on the base branch"},
{"y", "do it"},
{"esc or n", "do nothing"},
}, m.width, m.height)
}
if m.picking {
entries := make([][2]string, 0, len(m.pickList))
for i, is := range m.pickList {
@@ -391,7 +457,7 @@ func (m workModel) View() string {
keyHint("↑↓ or j k", "scroll"),
keyHint("t", "open a tool pane"),
keyHint("o", "choose a task"),
keyHint("m", "merge this task"),
keyHint("m", "merge this task (asks first)"),
keyHint("]", "tabs"),
keyHint("r", "read again"),
keyHint("R", "rebuild every pane"),
@@ -540,6 +606,37 @@ func (m workModel) reviewLines(width int) []string {
// mergeLines shows the last merge this screen started, read from the log the
// merge writes. Pressing m again is safe: the merge takes a lock.
// logLinesWork shows the output of the run chosen on the runs tab.
func (m workModel) logLinesWork(width int) []string {
if m.logRunID == "" {
return []string{"", mutedStyle2().Render(" choose a run on the runs tab, then press enter")}
}
if m.logText == "" {
return []string{"", mutedStyle2().Render(" no output recorded for this run")}
}
out := []string{"", dimStyle().Render(" run " + short(m.logRunID)), ""}
entries := decodeRunLog(m.logText)
if len(entries) > 2000 {
entries = entries[len(entries)-2000:]
}
for _, e := range entries {
colour := active.Text
switch e.stream {
case "thinking":
colour = active.Muted
case "prompt":
colour = active.Info
case "tool":
colour = active.Warning
case "event":
colour = active.Overlay
}
out = append(out, " "+dimStyle().Render(e.when)+" "+
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(truncateVisible(e.line, width-10)))
}
return out
}
func (m workModel) mergeLines(width int) []string {
out := []string{"", dimStyle().Render(" " + mergeLogPath()), ""}
for _, ln := range mergeLogTail(max(6, m.height-6)) {