Repository navigation
feat(ui): live per-repo rain status in --rain TUI (PR F) - #27
bschellenberger2600 wants to merge 23 commits into
Conversation
Show queued/running/retrying/timed out/frozen/failed/done states during the rain phase. Keys: s skip selected running repo, a abort all (partial summary). Highlight repos running past fetch_timeout_sec. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Overdue highlight ignores CLI timeout
- Rain progress TUI now receives opts.FetchTimeoutSec so overdue highlighting matches the CLI-resolved fetch timeout, including 0 to disable.
- ✅ Fixed: Skipped repos counted as frozen
- User-skip mid-flight now zeros the cancel-as-frozen delta before totals are accumulated, so the summary no longer reports skipped repos as frozen.
- ✅ Fixed: Skip can mislabel finished work
- StateSkipped is applied only when skip interrupts before any updated/failed/skipped outcomes, so completed fetch or sync work keeps its real status.
Or push these changes by commenting:
@cursor push ebdd559bfe
Preview (ebdd559bfe)
diff --git a/cmd/rain_tui_run.go b/cmd/rain_tui_run.go
--- a/cmd/rain_tui_run.go
+++ b/cmd/rain_tui_run.go
@@ -36,7 +36,7 @@
}
},
}
- _ = ui.RunRainProgressTUI(repos, events, ctrl, cfg.Global.FetchTimeoutSec)
+ _ = ui.RunRainProgressTUI(repos, events, ctrl, opts.FetchTimeoutSec)
totals := <-done
return printRainSummary(totals, opts.RunCtx)
}
diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go
--- a/cmd/rain_workers.go
+++ b/cmd/rain_workers.go
@@ -110,10 +110,17 @@
state := rainexec.StateDone
detail := ""
+ // User skip cancels repoCtx. Only label skipped when nothing useful
+ // finished; otherwise keep the real outcome (avoid false "skipped"
+ // after a completed fetch/sync). Drop cancel-as-frozen from totals.
+ userSkip := repoCtx != nil && repoCtx.Err() != nil
+ interruptedBeforeDone := userSkip &&
+ delta.updated == 0 && delta.failed == 0 && delta.skipped == 0
switch {
- case repoCtx != nil && repoCtx.Err() != nil:
+ case interruptedBeforeDone:
state = rainexec.StateSkipped
detail = "skipped"
+ delta = rainTotals{}
case delta.failed > 0:
state = rainexec.StateFailed
case delta.frozen > 0:You can send follow-ups to the cloud agent here.
…' into cursor/tui-rain-status-971d Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Use effective FetchTimeoutSec for overdue highlighting, classify skip vs done without inflating frozen totals, and add outcome helper tests. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
…d' into cursor/tui-rain-status-971d Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: TUI exit can deadlock rain workers
- After RunRainProgressTUI returns, runRainOnReposTUI now drains the events channel so emitRainEvent cannot block workers forever.
- ✅ Fixed: Quit key leaves fetches running silently
- The q key now aborts the rain run like a/ctrl+c, canceling in-flight fetches instead of only hiding the TUI.
- ✅ Fixed: Overdue color breaks on truncate
- formatRow truncates plain text before applying lipgloss overdue styling so ANSI reset sequences are never clipped.
Or push these changes by commenting:
@cursor push 13ba97c494
Preview (13ba97c494)
diff --git a/cmd/rain_tui_run.go b/cmd/rain_tui_run.go
--- a/cmd/rain_tui_run.go
+++ b/cmd/rain_tui_run.go
@@ -37,6 +37,11 @@
},
}
_ = ui.RunRainProgressTUI(repos, events, ctrl, opts.FetchTimeoutSec)
+ // Drain so workers never block on emitRainEvent after the TUI stops consuming.
+ go func() {
+ for range events {
+ }
+ }()
totals := <-done
return printRainSummary(totals, opts.RunCtx)
}
diff --git a/internal/ui/rain_run_model.go b/internal/ui/rain_run_model.go
--- a/internal/ui/rain_run_model.go
+++ b/internal/ui/rain_run_model.go
@@ -114,14 +114,12 @@
m.ctrl.Skip(row.path)
}
}
- case "a", "ctrl+c":
+ case "a", "q", "ctrl+c":
m.aborted = true
if m.ctrl.Abort != nil {
m.ctrl.Abort()
}
return m, tea.Quit
- case "q":
- return m, tea.Quit
}
}
return m, nil
@@ -149,7 +147,7 @@
title := lipgloss.NewStyle().Bold(true).Render("🌧 Rain in progress")
b.WriteString(title)
b.WriteString("\n")
- b.WriteString("↑/↓ select · s skip running repo · a abort all · q close when done\n\n")
+ b.WriteString("↑/↓ select · s skip running repo · a/q abort all\n\n")
for i, row := range m.rows {
line := m.formatRow(row, i == m.cursor)
@@ -199,11 +197,13 @@
if selected {
prefix = "▸ "
}
- line := prefix + label + " — " + status
+ // Truncate plain text first; runewidth treats ANSI CSI bytes as width and
+ // can clip a lipgloss reset if styling is applied before truncation.
+ line := runewidth.Truncate(prefix+label+" — "+status, max(40, m.width-2), "…")
if overdue {
line = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line)
}
- return runewidth.Truncate(line, max(40, m.width-2), "…")
+ return line
}
func max(a, b int) int {
diff --git a/internal/ui/rain_run_model_test.go b/internal/ui/rain_run_model_test.go
--- a/internal/ui/rain_run_model_test.go
+++ b/internal/ui/rain_run_model_test.go
@@ -1,10 +1,15 @@
package ui
import (
+ "fmt"
+ "strings"
"testing"
"time"
"github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/mattn/go-runewidth"
+ "github.com/muesli/termenv"
"github.com/git-rain/git-rain/internal/rainexec"
)
@@ -58,6 +63,22 @@
}
}
+func TestRainRunModel_QuitKeyAborts(t *testing.T) {
+ aborted := false
+ m := rainRunModel{
+ rows: []rainRepoRow{{path: "/a", name: "alpha", state: rainexec.StateRunning}},
+ ctrl: RainControls{Abort: func() { aborted = true }},
+ }
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+ fm := next.(rainRunModel)
+ if !fm.aborted || !aborted {
+ t.Fatal("expected q to abort the rain run")
+ }
+ if cmd == nil {
+ t.Fatal("expected quit cmd")
+ }
+}
+
func TestRainRunModel_OverdueHighlight(t *testing.T) {
m := rainRunModel{
timeoutSec: 2,
@@ -77,3 +98,34 @@
t.Fatal("expected overdue row to differ from on-time row")
}
}
+
+func TestRainRunModel_OverdueTruncateKeepsReset(t *testing.T) {
+ prev := lipgloss.ColorProfile()
+ lipgloss.SetColorProfile(termenv.ANSI256)
+ t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
+
+ m := rainRunModel{
+ timeoutSec: 1,
+ width: 40,
+ }
+ row := rainRepoRow{
+ name: strings.Repeat("long-repo-name-", 8),
+ state: rainexec.StateRunning,
+ startedAt: time.Now().Add(-10 * time.Second),
+ }
+ got := m.formatRow(row, false)
+ wantWidth := max(40, m.width-2)
+ if lipgloss.Width(got) != wantWidth {
+ t.Fatalf("lipgloss.Width = %d, want %d (ANSI reset likely clipped)", lipgloss.Width(got), wantWidth)
+ }
+ // Style-then-truncate would clip the reset; truncate-then-style keeps it.
+ if !strings.HasSuffix(got, "\x1b[0m") {
+ t.Fatalf("expected intact lipgloss reset suffix, got %q", got)
+ }
+ sec := int(time.Since(row.startedAt).Seconds())
+ plain := runewidth.Truncate(" "+row.name+fmt.Sprintf(" — running %ds", sec), wantWidth, "…")
+ want := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(plain)
+ if got != want {
+ t.Fatalf("formatRow = %q, want %q", got, want)
+ }
+}You can send follow-ups to the cloud agent here.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Timeouts never show timed out
- Quiet TUI workers left the human buffer empty, so timeout classification now uses report.Error and branch Messages instead.
Or push these changes by commenting:
@cursor push 62f52fdab2
Preview (62f52fdab2)
diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go
--- a/cmd/rain_outcome.go
+++ b/cmd/rain_outcome.go
@@ -10,7 +10,9 @@
// classifyRepoRainOutcome maps runRainOnRepo totals to a TUI state and adjusts
// counters when a per-repo cancel should count as skipped, not frozen.
-func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, output string) (rainexec.RepoState, rainTotals) {
+// Timeout detection uses the structured report (Error / branch Messages) so it
+// works when human CLI output is suppressed (quiet TUI rain phase).
+func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, report jsonRepoResult) (rainexec.RepoState, rainTotals) {
cancelled := repoCtx != nil && errors.Is(repoCtx.Err(), context.Canceled)
workDone := delta.updated > 0 || delta.skipped > 0
@@ -28,7 +30,7 @@
case delta.failed > 0:
return rainexec.StateFailed, delta
case delta.frozen > 0:
- if strings.Contains(output, "timed out") {
+ if repoRainTimedOut(report) {
return rainexec.StateTimedOut, delta
}
return rainexec.StateFrozen, delta
@@ -36,3 +38,15 @@
return rainexec.StateDone, delta
}
}
+
+func repoRainTimedOut(report jsonRepoResult) bool {
+ if strings.Contains(report.Error, "timed out") {
+ return true
+ }
+ for _, br := range report.Branches {
+ if strings.Contains(br.Message, "timed out") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go
--- a/cmd/rain_outcome_test.go
+++ b/cmd/rain_outcome_test.go
@@ -10,7 +10,7 @@
func TestClassifyRepoRainOutcome_SkipDoesNotCountFrozen(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
- state, delta := classifyRepoRainOutcome(ctx, rainTotals{frozen: 1}, "context canceled")
+ state, delta := classifyRepoRainOutcome(ctx, rainTotals{frozen: 1}, jsonRepoResult{Error: "context canceled"})
if state != rainexec.StateSkipped {
t.Fatalf("state = %q, want skipped", state)
}
@@ -22,7 +22,7 @@
func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
- state, delta := classifyRepoRainOutcome(ctx, rainTotals{updated: 1, frozen: 1}, "fetched")
+ state, delta := classifyRepoRainOutcome(ctx, rainTotals{updated: 1, frozen: 1}, jsonRepoResult{})
if state != rainexec.StateDone {
t.Fatalf("state = %q, want done", state)
}
@@ -30,3 +30,31 @@
t.Fatalf("delta frozen unchanged when work completed")
}
}
+
+func TestClassifyRepoRainOutcome_TimedOutFromReportError(t *testing.T) {
+ // Quiet TUI path never fills the human buffer; Error still carries the timeout.
+ state, _ := classifyRepoRainOutcome(context.Background(), rainTotals{frozen: 1}, jsonRepoResult{
+ Error: "timed out after 120s",
+ })
+ if state != rainexec.StateTimedOut {
+ t.Fatalf("state = %q, want timed_out", state)
+ }
+}
+
+func TestClassifyRepoRainOutcome_TimedOutFromBranchMessage(t *testing.T) {
+ state, _ := classifyRepoRainOutcome(context.Background(), rainTotals{frozen: 1}, jsonRepoResult{
+ Branches: []jsonBranchResult{{Message: "timed out after 30s"}},
+ })
+ if state != rainexec.StateTimedOut {
+ t.Fatalf("state = %q, want timed_out", state)
+ }
+}
+
+func TestClassifyRepoRainOutcome_FrozenWithoutTimeout(t *testing.T) {
+ state, _ := classifyRepoRainOutcome(context.Background(), rainTotals{frozen: 1}, jsonRepoResult{
+ Error: "could not reach remote — check your network and try again",
+ })
+ if state != rainexec.StateFrozen {
+ t.Fatalf("state = %q, want frozen", state)
+ }
+}
diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go
--- a/cmd/rain_workers.go
+++ b/cmd/rain_workers.go
@@ -110,7 +110,7 @@
var buf strings.Builder
emitHuman := !quiet && !rainJSON
delta, report := runRainOnRepo(reg, repo, repoOpts, fullSync, current, int(total), &buf, emitHuman)
- state, delta := classifyRepoRainOutcome(repoCtx, delta, buf.String())
+ state, delta := classifyRepoRainOutcome(repoCtx, delta, report)
detail := ""
if state == rainexec.StateSkipped {
detail = "skipped"You can send follow-ups to the cloud agent here.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Skip after branch skips looks done
- workDone now requires updated > 0 so pre-fetch branch skips no longer classify a cancelled repo as done or retain frozen counts.
Or push these changes by commenting:
@cursor push 0c85a58d15
Preview (0c85a58d15)
diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go
--- a/cmd/rain_outcome.go
+++ b/cmd/rain_outcome.go
@@ -12,7 +12,9 @@
// counters when a per-repo cancel should count as skipped, not frozen.
func classifyRepoRainOutcome(repoCtx context.Context, delta rainTotals, output string) (rainexec.RepoState, rainTotals) {
cancelled := repoCtx != nil && errors.Is(repoCtx.Err(), context.Canceled)
- workDone := delta.updated > 0 || delta.skipped > 0
+ // Only successful updates count as completed work. Branch skips (no-upstream,
+ // etc.) are recorded before fetch and must not mask a user cancel as done.
+ workDone := delta.updated > 0
if cancelled {
if workDone {
diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go
--- a/cmd/rain_outcome_test.go
+++ b/cmd/rain_outcome_test.go
@@ -30,3 +30,20 @@
t.Fatalf("delta frozen unchanged when work completed")
}
}
+
+func TestClassifyRepoRainOutcome_SkipAfterBranchSkipsNotDone(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ // Pre-fetch skips (e.g. no-upstream) plus frozen from a cancelled fetch must
+ // still classify as skipped, not done.
+ state, delta := classifyRepoRainOutcome(ctx, rainTotals{skipped: 1, frozen: 1}, "context canceled")
+ if state != rainexec.StateSkipped {
+ t.Fatalf("state = %q, want skipped", state)
+ }
+ if delta.frozen != 0 {
+ t.Fatalf("frozen = %d, want 0", delta.frozen)
+ }
+ if delta.skipped != 1 {
+ t.Fatalf("skipped = %d, want 1", delta.skipped)
+ }
+}You can send follow-ups to the cloud agent here.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Prevent workers from blocking after the rain-phase TUI exits by draining the events channel. Treat q like abort so fetches do not continue silently, and truncate plain row text before applying overdue styling so ANSI resets are not clipped.
Quiet TUI rain workers suppress per-repo output, so timeout detection now uses report.Error and branch messages instead of parsing the buffer. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Pre-fetch skipped outcomes (no-upstream, etc.) must not mark a cancelled repo as done or keep frozen counts from the interrupted fetch. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Skipped repos vanish from summary
- Cancelled outcomes now ensure skipped >= 1 in classifyRepoRainOutcome, and the pre-start worker cancel path increments the skipped total.
Or push these changes by commenting:
@cursor push 039723139f
Preview (039723139f)
diff --git a/cmd/rain_outcome.go b/cmd/rain_outcome.go
--- a/cmd/rain_outcome.go
+++ b/cmd/rain_outcome.go
@@ -25,6 +25,11 @@
d := delta
d.frozen = 0
d.failed = 0
+ // Paths that record cancel as frozen (e.g. --fetch-mainline) leave
+ // skipped at 0; ensure the repo still appears in the summary.
+ if d.skipped == 0 {
+ d.skipped = 1
+ }
return rainexec.StateSkipped, d
}
diff --git a/cmd/rain_outcome_test.go b/cmd/rain_outcome_test.go
--- a/cmd/rain_outcome_test.go
+++ b/cmd/rain_outcome_test.go
@@ -17,6 +17,9 @@
if delta.frozen != 0 {
t.Fatalf("frozen = %d, want 0", delta.frozen)
}
+ if delta.skipped != 1 {
+ t.Fatalf("skipped = %d, want 1", delta.skipped)
+ }
}
func TestClassifyRepoRainOutcome_SkipAfterWorkShowsDone(t *testing.T) {
diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go
--- a/cmd/rain_parallel_test.go
+++ b/cmd/rain_parallel_test.go
@@ -2,6 +2,7 @@
import (
"bytes"
+ "context"
"encoding/json"
"io"
"os"
@@ -9,9 +10,10 @@
"testing"
"time"
+ testutil "github.com/git-fire/git-testkit"
"github.com/git-rain/git-rain/internal/git"
+ "github.com/git-rain/git-rain/internal/rainexec"
"github.com/git-rain/git-rain/internal/registry"
- testutil "github.com/git-fire/git-testkit"
)
func TestRunRainOnRepos_ParallelStallDoesNotBlockHealthy(t *testing.T) {
@@ -135,3 +137,30 @@
}
}
}
+
+func TestRunRainReposParallel_PreStartCancelCountsSkipped(t *testing.T) {
+ repos := []git.Repository{{Name: "pending", Path: "/tmp/git-rain-never-started"}}
+ coord := newRainCoordinator(context.Background(), []string{repos[0].Path})
+ coord.SkipRepo(repos[0].Path)
+
+ progress := make(chan rainexec.Event, 4)
+ totals, _ := runRainReposParallel(®istry.Registry{}, repos, git.RainOptions{}, false, 1, progress, coord, true)
+ close(progress)
+
+ if totals.skipped != 1 {
+ t.Fatalf("totals.skipped = %d, want 1 (pre-start cancel must count in summary)", totals.skipped)
+ }
+ if totals.updated != 0 || totals.frozen != 0 || totals.failed != 0 {
+ t.Fatalf("unexpected non-skip totals: %+v", totals)
+ }
+
+ var sawSkipped bool
+ for e := range progress {
+ if e.State == rainexec.StateSkipped {
+ sawSkipped = true
+ }
+ }
+ if !sawSkipped {
+ t.Fatal("expected skipped progress event for pre-start cancel")
+ }
+}
diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go
--- a/cmd/rain_workers.go
+++ b/cmd/rain_workers.go
@@ -81,6 +81,7 @@
State: rainexec.StateSkipped,
Detail: "skipped",
})
+ atomic.AddInt64(&skipped, 1)
continue
}You can send follow-ups to the cloud agent here.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Per-repo cancel cleared frozen/failed without ensuring skipped >= 1, so --fetch-mainline cancel outcomes and pre-start worker skips vanished from summary totals while the TUI still showed skipped.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Abort summary omits queued repos
- Removed the early runCtx cancel continue so queued jobs after abort are counted as skipped and emit StateSkipped like the coordinator path.
Or push these changes by commenting:
@cursor push 1a76fa4617
Preview (1a76fa4617)
diff --git a/cmd/rain_parallel_test.go b/cmd/rain_parallel_test.go
--- a/cmd/rain_parallel_test.go
+++ b/cmd/rain_parallel_test.go
@@ -164,3 +164,39 @@
t.Fatal("expected skipped progress event for pre-start cancel")
}
}
+
+func TestRunRainReposParallel_ParentAbortCountsQueuedSkipped(t *testing.T) {
+ // Mirror TUI Abort: cancel per-repo contexts and the shared runCtx while more
+ // repos are queued than workers can run at once.
+ repos := []git.Repository{
+ {Name: "queued-a", Path: "/tmp/git-rain-never-a"},
+ {Name: "queued-b", Path: "/tmp/git-rain-never-b"},
+ {Name: "queued-c", Path: "/tmp/git-rain-never-c"},
+ }
+ paths := []string{repos[0].Path, repos[1].Path, repos[2].Path}
+ runCtx, abortRun := context.WithCancel(context.Background())
+ coord := newRainCoordinator(runCtx, paths)
+ coord.AbortAll()
+ abortRun()
+
+ progress := make(chan rainexec.Event, 16)
+ totals, _ := runRainReposParallel(®istry.Registry{}, repos, git.RainOptions{RunCtx: runCtx}, false, 1, progress, coord, true)
+ close(progress)
+
+ if totals.skipped != len(repos) {
+ t.Fatalf("totals.skipped = %d, want %d (abort must count queued repos)", totals.skipped, len(repos))
+ }
+ if totals.updated != 0 || totals.frozen != 0 || totals.failed != 0 {
+ t.Fatalf("unexpected non-skip totals: %+v", totals)
+ }
+
+ skippedEvents := 0
+ for e := range progress {
+ if e.State == rainexec.StateSkipped {
+ skippedEvents++
+ }
+ }
+ if skippedEvents != len(repos) {
+ t.Fatalf("skipped events = %d, want %d", skippedEvents, len(repos))
+ }
+}
diff --git a/cmd/rain_workers.go b/cmd/rain_workers.go
--- a/cmd/rain_workers.go
+++ b/cmd/rain_workers.go
@@ -67,13 +67,13 @@
defer workersWG.Done()
for job := range repoChan {
repo := job.repo
- if runCtx != nil && runCtx.Err() != nil {
- continue
- }
repoCtx := runCtx
if coord != nil {
repoCtx = coord.ContextFor(repo.Path)
}
+ // Parent abort cancels runCtx (and per-repo children). Count every
+ // not-yet-started job as skipped so the interrupted summary includes
+ // queued work, not only in-flight repos that already passed this check.
if repoCtx != nil && repoCtx.Err() != nil {
emitRainEvent(progress, rainexec.Event{
RepoPath: repo.Path,You can send follow-ups to the cloud agent here.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Rain status list never scrolls
- Added scrollOffset clamped to cursor and terminal height so View renders only the visible repo window.
Or push these changes by commenting:
@cursor push ce35609736
Preview (ce35609736)
diff --git a/internal/ui/rain_run_model.go b/internal/ui/rain_run_model.go
--- a/internal/ui/rain_run_model.go
+++ b/internal/ui/rain_run_model.go
@@ -23,23 +23,24 @@
}
type rainRepoRow struct {
- path, name string
- state rainexec.RepoState
- attempt int
+ path, name string
+ state rainexec.RepoState
+ attempt int
maxAttempts int
- startedAt time.Time
- detail string
+ startedAt time.Time
+ detail string
}
type rainRunModel struct {
- rows []rainRepoRow
- cursor int
- timeoutSec int
- events <-chan rainexec.Event
- ctrl RainControls
- aborted bool
- width int
- height int
+ rows []rainRepoRow
+ cursor int
+ scrollOffset int
+ timeoutSec int
+ events <-chan rainexec.Event
+ ctrl RainControls
+ aborted bool
+ width int
+ height int
}
type rainEventMsg rainexec.Event
@@ -91,6 +92,7 @@
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
+ m.scrollOffset = clampRainScroll(m.scrollOffset, m.cursor, m.listVisible(), len(m.rows))
return m, nil
case rainEventMsg:
m.applyEvent(rainexec.Event(msg))
@@ -103,10 +105,12 @@
if m.cursor > 0 {
m.cursor--
}
+ m.scrollOffset = clampRainScroll(m.scrollOffset, m.cursor, m.listVisible(), len(m.rows))
case "down", "j":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
+ m.scrollOffset = clampRainScroll(m.scrollOffset, m.cursor, m.listVisible(), len(m.rows))
case "s":
if m.ctrl.Skip != nil && m.cursor < len(m.rows) {
row := m.rows[m.cursor]
@@ -149,14 +153,52 @@
b.WriteString("\n")
b.WriteString("↑/↓ select · s skip running repo · a/q abort all\n\n")
- for i, row := range m.rows {
- line := m.formatRow(row, i == m.cursor)
- b.WriteString(line)
+ visible := m.listVisible()
+ offset := clampRainScroll(m.scrollOffset, m.cursor, visible, len(m.rows))
+ end := offset + visible
+ if end > len(m.rows) {
+ end = len(m.rows)
+ }
+ for i := offset; i < end; i++ {
+ b.WriteString(m.formatRow(m.rows[i], i == m.cursor))
b.WriteString("\n")
}
return b.String()
}
+// listVisible is how many repo rows fit below the fixed header.
+// Before the first WindowSizeMsg (height 0), show the full list.
+func (m rainRunModel) listVisible() int {
+ const headerLines = 3 // title, help, blank
+ if m.height <= 0 {
+ return len(m.rows)
+ }
+ n := m.height - headerLines
+ if n < 1 {
+ return 1
+ }
+ return n
+}
+
+func clampRainScroll(offset, cursor, visible, total int) int {
+ if visible <= 0 || total == 0 {
+ return 0
+ }
+ if offset > total-visible {
+ offset = total - visible
+ }
+ if offset < 0 {
+ offset = 0
+ }
+ if cursor < offset {
+ return cursor
+ }
+ if cursor >= offset+visible {
+ return cursor - visible + 1
+ }
+ return offset
+}
+
func (m rainRunModel) formatRow(row rainRepoRow, selected bool) string {
label := row.name
if label == "" {
diff --git a/internal/ui/rain_run_model_test.go b/internal/ui/rain_run_model_test.go
--- a/internal/ui/rain_run_model_test.go
+++ b/internal/ui/rain_run_model_test.go
@@ -20,10 +20,10 @@
}
started := time.Now()
m.applyEvent(rainexec.Event{
- RepoPath: "/a",
- State: rainexec.StateRunning,
- StartedAt: started,
- Attempt: 1,
+ RepoPath: "/a",
+ State: rainexec.StateRunning,
+ StartedAt: started,
+ Attempt: 1,
MaxAttempts: 2,
})
if m.rows[0].state != rainexec.StateRunning {
@@ -79,6 +79,56 @@
}
}
+func TestRainRunModel_ViewScrollsWithCursor(t *testing.T) {
+ rows := make([]rainRepoRow, 10)
+ for i := range rows {
+ rows[i] = rainRepoRow{path: fmt.Sprintf("/%d", i), name: fmt.Sprintf("repo-%02d", i), state: rainexec.StateQueued}
+ }
+ // height 6 → 3 header lines + 3 visible repo rows
+ m := rainRunModel{rows: rows, height: 6, width: 80, cursor: 0}
+
+ view := m.View()
+ if !strings.Contains(view, "repo-00") || strings.Contains(view, "repo-09") {
+ t.Fatalf("expected top of list visible, got:\n%s", view)
+ }
+
+ for range 9 {
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
+ m = next.(rainRunModel)
+ }
+ if m.cursor != 9 {
+ t.Fatalf("cursor = %d, want 9", m.cursor)
+ }
+ view = m.View()
+ if !strings.Contains(view, "repo-09") || strings.Contains(view, "repo-00") {
+ t.Fatalf("expected bottom of list visible after scrolling, got:\n%s", view)
+ }
+ if m.scrollOffset != 7 { // visible=3, cursor=9 → offset=7
+ t.Fatalf("scrollOffset = %d, want 7", m.scrollOffset)
+ }
+}
+
+func TestClampRainScroll(t *testing.T) {
+ tests := []struct {
+ offset, cursor, visible, total, want int
+ }{
+ {0, 0, 3, 10, 0},
+ {0, 2, 3, 10, 0},
+ {0, 3, 3, 10, 1},
+ {5, 4, 3, 10, 4},
+ {8, 9, 3, 10, 7},
+ {0, 0, 3, 2, 0},
+ {0, 0, 0, 10, 0},
+ }
+ for _, tc := range tests {
+ got := clampRainScroll(tc.offset, tc.cursor, tc.visible, tc.total)
+ if got != tc.want {
+ t.Fatalf("clampRainScroll(%d,%d,%d,%d)=%d, want %d",
+ tc.offset, tc.cursor, tc.visible, tc.total, got, tc.want)
+ }
+ }
+}
+
func TestRainRunModel_OverdueHighlight(t *testing.T) {
m := rainRunModel{
timeoutSec: 2,You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 42a4aac. Configure here.
Rain progress TUI now tracks a list viewport offset (same clamp logic as the repo picker) so running/overdue repos stay on screen when the repo count exceeds terminal height. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>


Stacked stall-review fix 6/6. Base:
cursor/fetch-retry-transient-971d(PR E).Summary
Rain phase TUI after
--rainrepo selection:fetch_timeout_secs— skip selected running repo (per-repo cancel)a/ Ctrl-C — abort entire rain (partial summary, non-zero exit)Tests
internal/ui/rain_run_model_test.go(event application, skip/abort keys, overdue line)Verification
go vet ./...— passgo test -race -count=1 ./...— pass except knownTestNetworkFetch_UnauthenticatedHTTPSFailsWithoutPrompt(PR test: make network auth fetch regression test deterministic #21)