Skip to content

feat(ui): live per-repo rain status in --rain TUI (PR F) - #27

Open
bschellenberger2600 wants to merge 23 commits into
cursor/fetch-retry-transient-971dfrom
cursor/tui-rain-status-971d
Open

bschellenberger2600 wants to merge 23 commits into
cursor/fetch-retry-transient-971dfrom
cursor/tui-rain-status-971d

Conversation

@bschellenberger2600

Copy link
Copy Markdown
Member

Stacked stall-review fix 6/6. Base: cursor/fetch-retry-transient-971d (PR E).

Summary

Rain phase TUI after --rain repo selection:

  • Live per-repo states: queued, running (+ elapsed), retrying n/m, timed out, frozen, failed, done, skipped
  • Highlight running repos past fetch_timeout_sec
  • s — 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

Open in Web Open in Cursor 

cursoragent and others added 2 commits October 11, 2026 11:07
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>
@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 90caf753-1d4f-463c-b27d-f211d42fbb9c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread cmd/rain_tui_run.go Resolved
Comment thread cmd/rain_workers.go Resolved
Comment thread cmd/rain_workers.go Resolved
cursoragent and others added 4 commits October 11, 2026 12:25
…' 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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread cmd/rain_tui_run.go Resolved
Comment thread internal/ui/rain_run_model.go Resolved
Comment thread internal/ui/rain_run_model.go Resolved
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread cmd/rain_outcome.go Resolved
cursoragent and others added 3 commits October 12, 2026 18:02
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread cmd/rain_outcome.go Resolved
cursoragent and others added 6 commits October 13, 2026 12:25
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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(&registry.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.

Comment thread cmd/rain_outcome.go Resolved
cursoragent and others added 3 commits October 13, 2026 19:28
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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(&registry.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.

Comment thread cmd/rain_workers.go Resolved
cursoragent and others added 2 commits October 13, 2026 19:57
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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.

Create PR

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.

Comment thread internal/ui/rain_run_model.go Resolved
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants