Skip to content

feat(ui): garden mode pacing, seed throttle, and flower colors#10

Merged
bschellenberger2600 merged 8 commits into
mainfrom
feature/garden-rain-mode
Apr 19, 2026
Merged

feat(ui): garden mode pacing, seed throttle, and flower colors#10
bschellenberger2600 merged 8 commits into
mainfrom
feature/garden-rain-mode

Conversation

@bschellenberger2600
Copy link
Copy Markdown
Member

@bschellenberger2600 bschellenberger2600 commented Apr 19, 2026

Summary

Garden rain mode is tuned to feel less rushed and more readable: storm pacing tracks rain_tick_ms and garden width, sky seeds are no longer independent per-drop floods, and flower heads use per-column accent colors.

Changes

  • Wall-clock pacing: applyGardenStormWallClockScale stretches moisture, bloom, and seed spawn from config/TUI presets (normal vs rare/calm) and tick/width.
  • Seed discipline: batch caps (binomial + per-frame ceiling), then a flying-seed budget that tightens for young plots and relaxes toward full bloom (gardenSeedThrottleRelief).
  • Defaults: slower growth, fewer offspring, updated example TOML/comments where defaults shifted.
  • Flower color: flowerTint chosen when a seed first plants a column; bloom and eternal heads use gardenFlowerAccentHex.

Testing

go test -race -count=1 ./...


Note

Medium Risk
Moderate risk due to substantial changes in the TUI animation engine and settings/config plumbing; regressions could affect TUI rendering/performance but do not touch git/auth/data paths.

Overview
Improves the garden rain animation mode with wall-clock–based pacing (scaled by rain_tick_ms and terminal width), seed throttling (batch caps plus a maturity-based in-flight seed budget), and per-column flower accent colors that persist into the post-storm sunny state.

Adds new [ui] garden_* config keys (defaulting to 0 to mean “unset”) and wires them through config loading, runtime tuning (SetGardenTuning), and the settings UI (garden-only rows). Updates the wave strip rendering to support a sunny-sky variant and expands tests around garden rendering, tuning resolution, throttling, and lifecycle transitions.

Reviewed by Cursor Bugbot for commit 95062e7. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added a "garden" rain animation mode with lifecycle: seed drift → growth stages → wither/reseeding → storms clear to persistent blooms.
    • Added garden-only tuning options (seed rate, growth pace, bloom/wither durations, offspring behavior) shown in settings when garden mode is active.
  • Documentation

    • Expanded README and example config with garden mode usage, advanced tuning TOML examples, and notes that defaults preserve existing behavior.
  • Tests

    • Added tests validating garden rendering, lifecycle, tuning resolution, and simulation limits.

cursoragent and others added 4 commits April 17, 2026 16:13
Introduce ui.rain_animation_mode "matrix" with falling half-width glyphs
and a green gradient, matching the existing column/drift pattern. Settings
TUI lists the new option.

Normalize each inner panel line with lipgloss Width so rows with wide
emoji cannot exceed the inner cell count and skew rounded borders.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Seeds and rain fall under a cloud row; rain waters plots through growth
stages to full bloom, then wither cycles spawn new seeds. When plant
visuals cover about 80% of the rain strip, rain stops, the sky clears to
sun and blue tones, and plants stay in an eternal bloom state. Settings
UI lists the new mode; switching animation mode recreates the background
so garden state initializes correctly.
Garden mode previously raced through plants because every raindrop that
touched the soil row added moisture and lifecycle thresholds were small.
Each garden column now accepts at most one rain hit per frame (with a
small probabilistic chance of an extra hit so dense rain still reads as
soaking), moisture thresholds are larger, and bloom/wither timing has a
randomized lifetime so plants don't all die in unison.

Dying plants now scatter 2-3 seeds at jittered X positions around the
parent column instead of self-replacing, giving the meadow a more
organic spread before the storm clears.

Tuning is centralized in a new GardenTuning struct on RainBackground.
Defaults live in DefaultGardenTuning, and a small set of advanced TOML
keys under [ui] (garden_seed_rate, garden_growth_pace, bloom/wither
durations, offspring bounds) feed through ResolveGardenTuning. The
settings TUI is intentionally left alone; the keys are documented in
README and the example config so power users can tweak pacing without
cluttering the in-app picker.
Scale storm completion toward wall-clock targets using rain_tick_ms,
terminal width, and growth/seed TUI presets. Lower default moisture and
seed density; cap sky seeds per refill batch.

Throttle flying seeds by count and a stage-based relief curve so young
beds stay sparse and mature bloom can carry more seeds.

Assign a stable per-column flower tint when a seed first plants; bloom
and eternal heads draw from a shared accent palette.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

Adds a new "garden" rain animation mode with configuration, UI controls, lifecycle simulation, rendering, and tests. Garden introduces seed vs rain drops, per-column garden plots that progress through planting→sprout→bud→bloom→wither→eternal, offspring spawning, storm completion logic, and tuning parameters applied from config/UI.

Changes

Cohort / File(s) Summary
Documentation & Example Config
README.md, internal/config/defaults.go
Documented rain_animation_mode = "garden" and added an advanced [ui] TOML block showing optional garden_* tuning keys and their lifecycle semantics.
Config Types & Defaults
internal/config/types.go, internal/config/loader.go
Added UIRainAnimationGarden constant; extended UIConfig with eight exported garden tuning fields (GardenSeedRate, GardenGrowthPace, GardenBloomDurationBase, GardenBloomDurationJitter, GardenWitherDuration, GardenOffspringMin, GardenOffspringMax, GardenOffspringSpread) and registered default values in loader.
UI Settings & Interaction
internal/ui/config_view.go, internal/ui/repo_selector.go, internal/ui/view_layout.go
Exposed garden as a selectable rain mode; added conditional garden-specific settings rows and dynamic visible-row indexing; added garden tuning resolution/apply helpers; changed RenderRainWave callsites to use model-level strip renderer.
Animation Engine / Rendering
internal/ui/rain_bg.go
Implemented garden simulation: RainDrop.IsSeed, per-column gardenPlot states, moisture-driven stage progression, offspring spawning, storm-to-sunny transition, garden color/palette rendering; added GardenTuning type, DefaultGardenTuning, ResolveGardenTuning, and RainBackground.SetGardenTuning; changed RenderRainWave signature to include gardenSunny flag.
Tests
internal/ui/rain_bg_test.go
Added garden-focused tests: Render width checks, sky-seed batching/throttling, tuning resolution/clamping, moisture caps, offspring scattering bounds, storm completion → sunny/eternal plant behavior.

Sequence Diagram

sequenceDiagram
    actor User
    participant ConfigView as Config View
    participant RepoSelector as RepoSelector Model
    participant RainBg as Rain Background
    participant Renderer as Frame Renderer

    User->>ConfigView: Select "garden" mode + tune parameters
    ConfigView->>RepoSelector: Apply config changes
    RepoSelector->>RepoSelector: gardenTuningFromConfig(cfg, rainTickMS, width)
    RepoSelector->>RainBg: SetGardenTuning(resolved tuning)
    RainBg->>RainBg: Initialize garden plots & seed drops

    loop Each Frame
        RepoSelector->>RainBg: Update()
        RainBg->>RainBg: Apply per-column watering caps
        RainBg->>RainBg: Advance plant lifecycle stages
        RainBg->>RainBg: Spawn offspring on wither
        RainBg->>RainBg: Check coverage → set GardenSunny

        RepoSelector->>RepoSelector: renderRainWaveStrip(width)
        RepoSelector->>Renderer: RenderRainWave(..., gardenSunny)
        Renderer->>Renderer: Draw clouds/sun + plant glyphs
        Renderer-->>User: Display animated garden
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇
Seeds in rain, a hopping cheer,
Petals wake where droplets steer,
Buds to blooms, then storms set free,
Sun returns — the garden's glee,
I nibble joy beneath the tree.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: garden mode tuning optimizations (pacing), seed throttling logic, and flower color features are all central to this PR.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/garden-rain-mode

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 and usage tips.

Keep garden mode, pacing, and tests; integrate matrix subliminal marquee
from main (spawn + drop updates, RenderRainWave, matrix_subliminal.go).
Resolve config/docs comments and rain_bg_test for four-arg RenderRainWave.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/ui/rain_bg.go (1)

622-680: ⚠️ Potential issue | 🟠 Major

Consume garden drops after their first landing.

Line 658 uses p.Y >= maxDropY, but the drop remains alive until the later cleanup pass. That lets one rain/seed drop apply moisture or seed boost on multiple frames, including after it has moved below the landing band, which bypasses the new pacing/throttle behavior.

Proposed fix
-		if rb.Mode == config.UIRainAnimationGarden && p.Y >= maxDropY && rb.GardenPlots != nil && p.X >= 0 && p.X < len(rb.GardenPlots) {
+		if rb.Mode == config.UIRainAnimationGarden && p.Y >= maxDropY && p.Y < rb.Height && rb.GardenPlots != nil && p.X >= 0 && p.X < len(rb.GardenPlots) {
 			g := &rb.GardenPlots[p.X]
 			if p.IsSeed {
 				if g.stage == gardenStageNone {
 					g.stage = gardenStagePlanted
@@
 				} else if rb.Garden.RainAbsorbExtraChance > 0 && rand.Float64() < rb.Garden.RainAbsorbExtraChance {
 					rb.gardenWaterPlot(g)
 				}
 			}
+			p.Age = p.MaxAge
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/rain_bg.go` around lines 622 - 680, The landing logic uses p.Y >=
maxDropY but doesn't remove/consume the drop, so a single drop can apply effects
repeatedly; after handling landing effects in both the advanced (rb.Flowers) and
garden (rb.GardenPlots) branches (inside the rb.Mode checks), mark the drop as
consumed so it won't apply again — e.g., set p.Alive = false (or p.Y = rb.Height
/ set a consumed flag) immediately after calling rb.gardenWaterPlot or
incrementing drops / applying seed boost; update references to p (and
gardenWatered) accordingly so subsequent frames skip this drop.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/config/types.go`:
- Around line 105-108: The inline comment for GardenOffspringMin and
GardenOffspringMax is stale (mentions "currently 1 and 2") and should be
reconciled with the actual defaults returned by DefaultGardenTuning() and the
README example; update the comment to reflect the real defaults (e.g.,
"currently 2 and 3") or adjust DefaultGardenTuning()/README to match the comment
so they are consistent, ensuring references to GardenOffspringMin and
GardenOffspringMax in defaults.go and README are all aligned.
- Around line 80-83: Update the stale comment above the Garden mode tuning block
to remove the "three rows" claim and reflect reality: mention that this block
defines eight garden tuning fields for rain_animation_mode = "garden" and that
these fields are not surfaced in the in-app settings TUI (they fall back to
built-in defaults if left zero). Edit the comment near the Garden mode tuning
declaration in types.go to describe the correct number of fields and current TUI
visibility instead of the old "three rows" wording.

In `@internal/ui/rain_bg_test.go`:
- Around line 77-101: The test is nondeterministic because
gardenSkySeedsForBatch uses random binomial sampling; to guarantee a nonzero
result in the mature-garden branch set a deterministic, guaranteed seed rate
(e.g. rb.Garden.SeedSpawnRate = 1.0) before calling
rb.gardenSkySeedsForBatch(40) after you clear rb.Drops and flip GardenPlots to
gardenStageBloom, so the throttle ceiling (checked via
rb.gardenMaxFlyingSkySeeds(rb.gardenSeedThrottleRelief())) is what’s being
tested rather than randomness.

In `@internal/ui/rain_bg.go`:
- Line 486: The S1009 lint failure is caused by a redundant nil check on the
slice rb.GardenPlots; remove the explicit nil comparison and replace the
condition with only len(rb.GardenPlots) == 0 (i.e., change the if that currently
reads "if rb.GardenPlots == nil || len(rb.GardenPlots) == 0" to check only
length). Update any related branches in the same function (e.g., in rain_bg.go
where rb.GardenPlots is used) to rely on len for emptiness checks and eliminate
the nil check to satisfy golangci-lint.

In `@internal/ui/repo_selector.go`:
- Around line 764-773: applyGardenTuning currently dereferences m.cfg (accessing
m.cfg.UI.RainTickMS) which can be nil in static-mode; guard against this by
checking m.cfg != nil before reading its UI.RainTickMS (or by using a local tick
default when m.cfg is nil) and then call
rb.SetGardenTuning(gardenTuningFromConfig(m.cfg, tick, rb.Width)) as before;
update the applyGardenTuning function to compute tick using m.cfg only when
m.cfg != nil (falling back to config.DefaultUIRainTickMS) so the method no
longer panics when m.cfg is nil.

In `@README.md`:
- Line 346: The README's commented default for garden_seed_rate (0.10) conflicts
with the actual default used in code (garden_seed_rate = 0.055); update the
README.md entry so the commented default matches the canonical value defined in
internal/config/types.go and internal/config/defaults.go (garden_seed_rate =
0.055), or alternatively change the code defaults in internal/config/types.go
and internal/config/defaults.go to 0.10 if that was intended—ensure all three
places reference the same numeric default for garden_seed_rate.

---

Outside diff comments:
In `@internal/ui/rain_bg.go`:
- Around line 622-680: The landing logic uses p.Y >= maxDropY but doesn't
remove/consume the drop, so a single drop can apply effects repeatedly; after
handling landing effects in both the advanced (rb.Flowers) and garden
(rb.GardenPlots) branches (inside the rb.Mode checks), mark the drop as consumed
so it won't apply again — e.g., set p.Alive = false (or p.Y = rb.Height / set a
consumed flag) immediately after calling rb.gardenWaterPlot or incrementing
drops / applying seed boost; update references to p (and gardenWatered)
accordingly so subsequent frames skip this drop.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c785b46-e3c9-4ce6-8b21-de049f76be26

📥 Commits

Reviewing files that changed from the base of the PR and between 129ec90 and 5533a10.

📒 Files selected for processing (11)
  • README.md
  • internal/config/defaults.go
  • internal/config/loader.go
  • internal/config/types.go
  • internal/ui/config_view.go
  • internal/ui/panel_layout.go
  • internal/ui/panel_layout_test.go
  • internal/ui/rain_bg.go
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
  • internal/ui/view_layout.go

Comment thread internal/config/types.go
Comment on lines +80 to +83
// Garden mode tuning (used when rain_animation_mode = "garden").
// In the settings TUI, three rows appear under Rain animation mode; any
// field left at zero here still falls back to built-in defaults at runtime.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stale doc: "three rows" claim.

The comment asserts that "three rows appear under Rain animation mode" in the settings TUI, but this block introduces eight garden tuning fields. Since the README explicitly states these are not surfaced in the in-app settings TUI, this inline comment is likely stale and misleading for future maintainers.

✏️ Suggested wording
 	// Garden mode tuning (used when rain_animation_mode = "garden").
-	// In the settings TUI, three rows appear under Rain animation mode; any
-	// field left at zero here still falls back to built-in defaults at runtime.
+	// These are TOML-only (not exposed in the in-app settings TUI). Any field
+	// left at zero falls back to built-in defaults at runtime.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Garden mode tuning (used when rain_animation_mode = "garden").
// In the settings TUI, three rows appear under Rain animation mode; any
// field left at zero here still falls back to built-in defaults at runtime.
// Garden mode tuning (used when rain_animation_mode = "garden").
// These are TOML-only (not exposed in the in-app settings TUI). Any field
// left at zero falls back to built-in defaults at runtime.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/types.go` around lines 80 - 83, Update the stale comment
above the Garden mode tuning block to remove the "three rows" claim and reflect
reality: mention that this block defines eight garden tuning fields for
rain_animation_mode = "garden" and that these fields are not surfaced in the
in-app settings TUI (they fall back to built-in defaults if left zero). Edit the
comment near the Garden mode tuning declaration in types.go to describe the
correct number of fields and current TUI visibility instead of the old "three
rows" wording.

Comment thread internal/config/types.go
Comment on lines +105 to +108
// GardenOffspringMin/Max bound how many seeds a dying plant scatters.
// 0 = use defaults (currently 1 and 2).
GardenOffspringMin int `mapstructure:"garden_offspring_min" toml:"garden_offspring_min,omitempty"`
GardenOffspringMax int `mapstructure:"garden_offspring_max" toml:"garden_offspring_max,omitempty"`
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stale doc: offspring defaults.

The comment says "currently 1 and 2" but the README example and defaults.go example TOML list garden_offspring_min = 2 and garden_offspring_max = 3. Please reconcile the doc with the actual DefaultGardenTuning() values so users don't get surprised by non-zero overrides behaving differently than documented.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/types.go` around lines 105 - 108, The inline comment for
GardenOffspringMin and GardenOffspringMax is stale (mentions "currently 1 and
2") and should be reconciled with the actual defaults returned by
DefaultGardenTuning() and the README example; update the comment to reflect the
real defaults (e.g., "currently 2 and 3") or adjust DefaultGardenTuning()/README
to match the comment so they are consistent, ensuring references to
GardenOffspringMin and GardenOffspringMax in defaults.go and README are all
aligned.

Comment thread internal/ui/rain_bg_test.go
Comment thread internal/ui/rain_bg.go Outdated
// the global flying-seed ceiling so young plants see few sky seeds, and mature
// meadows can carry more falling seeds.
func (rb *RainBackground) gardenSeedThrottleRelief() float64 {
if rb.GardenPlots == nil || len(rb.GardenPlots) == 0 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n internal/ui/rain_bg.go | sed -n '480,495p'

Repository: git-fire/git-rain

Length of output: 648


🌐 Web query:

golangci-lint S1009 rule staticcheck

💡 Result:

The S1009 rule from the staticcheck linter in golangci-lint flags redundant nil checks before calling len on slices, maps, or channels. Since len returns 0 for nil values of these types, the explicit nil check is unnecessary. Example flagged code: if x != nil && len(x) != 0 {} Suggested fix: if len(x) != 0 {} This check is part of the 'simple' (S) category in staticcheck, available since 2017.1. In golangci-lint, staticcheck is enabled by default in many configurations, and S1009 reports appear in projects like argo-cd. To configure in golangci-lint .golangci.yml: linters: enable: - staticcheck To disable S1009 specifically: linters: settings: staticcheck: checks: ["all", "-S1009"]

Citations:


Fix the CI-blocking S1009 lint failure at line 492.

len is defined for nil slices in Go, so the nil check is redundant and currently fails golangci-lint.

Proposed fix
-	if rb.GardenPlots == nil || len(rb.GardenPlots) == 0 {
+	if len(rb.GardenPlots) == 0 {
 		return 0
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if rb.GardenPlots == nil || len(rb.GardenPlots) == 0 {
if len(rb.GardenPlots) == 0 {
return 0
}
🧰 Tools
🪛 GitHub Actions: CI

[error] 486-486: golangci-lint: S1009 (staticcheck) should omit nil check; len() for nil slices is defined as zero

🪛 GitHub Check: lint

[failure] 486-486:
S1009: should omit nil check; len() for nil slices is defined as zero (staticcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/rain_bg.go` at line 486, The S1009 lint failure is caused by a
redundant nil check on the slice rb.GardenPlots; remove the explicit nil
comparison and replace the condition with only len(rb.GardenPlots) == 0 (i.e.,
change the if that currently reads "if rb.GardenPlots == nil ||
len(rb.GardenPlots) == 0" to check only length). Update any related branches in
the same function (e.g., in rain_bg.go where rb.GardenPlots is used) to rely on
len for emptiness checks and eliminate the nil check to satisfy golangci-lint.

Comment thread internal/ui/repo_selector.go
Comment thread README.md Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

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: Nil pointer dereference on m.cfg in applyGardenTuning
    • applyGardenTuning now resolves rain tick from config.DefaultUIRainTickMS unless m.cfg is non-nil with a positive RainTickMS, matching the constructor and avoiding a nil dereference on WindowSizeMsg.
Preview (32dc264ab9)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -327,6 +327,36 @@
 GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain

+### Garden mode tuning (advanced)
+
+Picking rain_animation_mode = "garden" swaps the rain background for a
+slow-paced lifecycle: seeds drift down with the rain, plants progress through
+sprout, bud, and bloom, then wither and scatter 2–3 new seeds nearby. When the
+visible plants cover roughly 80% of the rain strip, the storm clears, the sun
+comes out, and the surviving flowers stay forever.
+
+The defaults aim for a calm pace, but a few advanced TOML keys are available
+under [ui] for tweaking. They are intentionally not surfaced in the
+in-app settings TUI — leave any unset (or set to 0) to keep the default.
+
+toml +[ui] +rain_animation_mode = "garden" + +# garden_seed_rate = 0.10 # fraction of new sky drops that fall as seeds (0..1) +# garden_growth_pace = 1.0 # multiplier on stage moisture thresholds (>1 = slower) +# garden_bloom_duration_base = 60 # min frames a flower lingers in full bloom +# garden_bloom_duration_jitter = 40 # extra random frames added to bloom lifetime +# garden_wither_duration = 28 # frames a withered plant lingers before re-seeding +# garden_offspring_min = 2 # minimum seeds a dying plant scatters +# garden_offspring_max = 3 # maximum seeds a dying plant scatters +# garden_offspring_spread = 3 # X-jitter half-width around the parent column +
+
+garden_growth_pace is the most useful single dial: set it to 2.0 to roughly
+halve growth speed, or 0.5 to roughly double it. The other knobs trade
+visual density (more or fewer seeds, longer or shorter blooms) for clarity.
+

Config file, locks, and crashes

Registry (repos.toml) — Writes use a cross-process lock file (repos.toml.lock), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use --force-unlock-registry in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other git-rain is running.

diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -114,7 +114,8 @@
show_rain_animation = true

Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),

-# or "matrix" (falling code characters)
+# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out)
rain_animation_mode = "basic"

Show flavor quotes in the TUI banner

@@ -131,5 +132,19 @@

Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"

color_profile = "storm"
+
+# --- Garden mode tuning (advanced) -----------------------------------------
+# These keys only affect rain_animation_mode = "garden". Leave them unset
+# (or at 0) to use the built-in defaults; tweak to make growth slower or
+# offspring more (or less) prolific.
+#
+# garden_seed_rate = 0.055 # sky seed density (0..1); runtime caps bursts per frame
+# garden_growth_pace = 1.0 # multiplier on stage moisture thresholds (>1 = slower)
+# garden_bloom_duration_base = 60 # min frames a flower lingers in full bloom
+# garden_bloom_duration_jitter = 40 # extra random frames added to bloom lifetime
+# garden_wither_duration = 28 # frames a withered plant lingers before re-seeding
+# garden_offspring_min = 1 # minimum seeds a dying plant scatters
+# garden_offspring_max = 2 # maximum seeds a dying plant scatters
+# garden_offspring_spread = 2 # X-jitter half-width around the parent column
`
}

diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -97,6 +97,17 @@
v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
v.SetDefault("ui.rain_tick_ms", defaults.UI.RainTickMS)
v.SetDefault("ui.color_profile", defaults.UI.ColorProfile)
+

  • // Garden tuning keys default to zero so the runtime can detect "unset"
  • // and substitute the built-in defaults from DefaultGardenTuning().
  • v.SetDefault("ui.garden_seed_rate", defaults.UI.GardenSeedRate)
  • v.SetDefault("ui.garden_growth_pace", defaults.UI.GardenGrowthPace)
  • v.SetDefault("ui.garden_bloom_duration_base", defaults.UI.GardenBloomDurationBase)
  • v.SetDefault("ui.garden_bloom_duration_jitter", defaults.UI.GardenBloomDurationJitter)
  • v.SetDefault("ui.garden_wither_duration", defaults.UI.GardenWitherDuration)
  • v.SetDefault("ui.garden_offspring_min", defaults.UI.GardenOffspringMin)
  • v.SetDefault("ui.garden_offspring_max", defaults.UI.GardenOffspringMax)
  • v.SetDefault("ui.garden_offspring_spread", defaults.UI.GardenOffspringSpread)
    }

// Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on

diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -57,7 +57,8 @@
ShowRainAnimation bool mapstructure:"show_rain_animation" toml:"show_rain_animation"

// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
  • // or "matrix" (falling code glyphs in the same column pattern).
  • // "matrix" (falling code glyphs in the same column pattern), or "garden"

  • // (seeds, rain, growth, then sun).
    RainAnimationMode string mapstructure:"rain_animation_mode" toml:"rain_animation_mode"

    // Show flavor quotes: TUI banner plus CLI motivation lines.
    @@ -76,6 +77,40 @@
    // Color profile for rain and TUI accents.
    // Options: "storm", "drizzle", "monsoon", "rainbow", "synthwave".
    ColorProfile string mapstructure:"color_profile" toml:"color_profile"

  • // Garden mode tuning (used when rain_animation_mode = "garden").

  • // In the settings TUI, three rows appear under Rain animation mode; any

  • // field left at zero here still falls back to built-in defaults at runtime.

  • // GardenSeedRate is the target probability (0..1) for sky seeds; the

  • // simulator caps seeds per frame, so this reads as density, not i.i.d.

  • // rolls. Lower = rarer; default ~0.055.

  • GardenSeedRate float64 mapstructure:"garden_seed_rate" toml:"garden_seed_rate,omitempty"

  • // GardenGrowthPace multiplies the moisture thresholds for every growth

  • // stage. >1 slows growth; <1 speeds it up; 0 = use defaults.

  • GardenGrowthPace float64 mapstructure:"garden_growth_pace" toml:"garden_growth_pace,omitempty"

  • // GardenBloomDurationBase is the minimum bloom lifetime in animation

  • // frames before a flower starts to wither. 0 = default.

  • GardenBloomDurationBase int mapstructure:"garden_bloom_duration_base" toml:"garden_bloom_duration_base,omitempty"

  • // GardenBloomDurationJitter is added on top of the base bloom duration

  • // (uniform random in [0, jitter)). 0 = default.

  • GardenBloomDurationJitter int mapstructure:"garden_bloom_duration_jitter" toml:"garden_bloom_duration_jitter,omitempty"

  • // GardenWitherDuration is how many frames a withered plant lingers

  • // before crumbling and seeding offspring. 0 = default.

  • GardenWitherDuration int mapstructure:"garden_wither_duration" toml:"garden_wither_duration,omitempty"

  • // GardenOffspringMin/Max bound how many seeds a dying plant scatters.

  • // 0 = use defaults (currently 1 and 2).

  • GardenOffspringMin int mapstructure:"garden_offspring_min" toml:"garden_offspring_min,omitempty"

  • GardenOffspringMax int mapstructure:"garden_offspring_max" toml:"garden_offspring_max,omitempty"

  • // GardenOffspringSpread is the half-width X jitter applied around the

  • // parent column when scattering offspring seeds. 0 = default.

  • GardenOffspringSpread int mapstructure:"garden_offspring_spread" toml:"garden_offspring_spread,omitempty"
    }

const (
@@ -91,6 +126,7 @@
UIRainAnimationBasic = "basic"
UIRainAnimationAdvanced = "advanced"
UIRainAnimationMatrix = "matrix"

  • UIRainAnimationGarden = "garden"
    )

// UIColorProfiles returns valid built-in UI color profile names.

diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go
--- a/internal/ui/config_view.go
+++ b/internal/ui/config_view.go
@@ -41,6 +41,7 @@
config.UIRainAnimationBasic,
config.UIRainAnimationAdvanced,
config.UIRainAnimationMatrix,

  •   config.UIRainAnimationGarden,
    
    }},
    {label: "Show flavor quotes", kind: configRowBool},
    {label: "Flavor quote behavior", kind: configRowEnum, options: []string{
    @@ -57,11 +58,122 @@
    {label: "Custom hex palette", kind: configRowComingSoon},
    }

-func configRowValue(i int, cfg *config.Config) string {
+// Garden settings rows appear in the menu only when rain mode is garden,
+// directly under "Rain animation mode" (see logicalRowIndex).
+var gardenSettingsConfigRows = []configRow{

  • {label: "Garden growth pace", kind: configRowEnum, options: []string{"calm", "normal", "fast"}},
  • {label: "Garden seed rate", kind: configRowEnum, options: []string{"rare", "normal", "often"}},
  • {label: "Garden offspring", kind: configRowEnum, options: []string{"few", "default", "many"}},
    +}

+func gardenSettingsRowCount(cfg *config.Config) int {

  • if cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationGarden) {
  •   return len(gardenSettingsConfigRows)
    
  • }
  • return 0
    +}

+func visibleConfigRowCount(cfg *config.Config) int {

  • return len(configRows) + gardenSettingsRowCount(cfg)
    +}

+// logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
+// or len(configRows)+k for garden-only rows.
+func logicalRowIndex(visibleI int, cfg *config.Config) int {

  • g := gardenSettingsRowCount(cfg)
  • if g == 0 {
  •   return visibleI
    
  • }
  • if visibleI < 5 {
  •   return visibleI
    
  • }
  • if visibleI < 5+g {
  •   return len(configRows) + (visibleI - 5)
    
  • }
  • return visibleI - g
    +}

+func configRowAt(visibleI int, cfg *config.Config) configRow {

  • li := logicalRowIndex(visibleI, cfg)
  • if li < len(configRows) {
  •   return configRows[li]
    
  • }
  • gi := li - len(configRows)
  • if gi >= 0 && gi < len(gardenSettingsConfigRows) {
  •   return gardenSettingsConfigRows[gi]
    
  • }
  • return configRows[len(configRows)-1]
    +}

+func clampConfigCursor(cfg *config.Config, cur int) int {

  • n := visibleConfigRowCount(cfg)
  • if n <= 0 {
  •   return 0
    
  • }
  • if cur >= n {
  •   return n - 1
    
  • }
  • if cur < 0 {
  •   return 0
    
  • }
  • return cur
    +}

+func gardenGrowthPaceLabel(cfg *config.Config) string {
if cfg == nil {

  •   return "normal"
    
  • }
  • p := cfg.UI.GardenGrowthPace
  • if p <= 0 {
  •   return "normal"
    
  • }
  • if p >= 1.2 {
  •   return "calm"
    
  • }
  • if p < 0.9 {
  •   return "fast"
    
  • }
  • return "normal"
    +}

+func gardenSeedRateLabel(cfg *config.Config) string {

  • if cfg == nil {
  •   return "normal"
    
  • }
  • s := cfg.UI.GardenSeedRate
  • if s <= 0 {
  •   return "normal"
    
  • }
  • if s < 0.08 {
  •   return "rare"
    
  • }
  • if s > 0.12 {
  •   return "often"
    
  • }
  • return "normal"
    +}

+func gardenOffspringLabel(cfg *config.Config) string {

  • if cfg == nil {
  •   return "default"
    
  • }
  • if cfg.UI.GardenOffspringMin <= 0 && cfg.UI.GardenOffspringMax <= 0 {
  •   return "default"
    
  • }
  • if cfg.UI.GardenOffspringMin >= 3 {
  •   return "many"
    
  • }
  • if cfg.UI.GardenOffspringMax <= 2 && cfg.UI.GardenOffspringMin > 0 {
  •   return "few"
    
  • }
  • return "default"
    +}

+func configRowValue(visibleI int, cfg *config.Config) string {

  • if cfg == nil {
    return ""
    }
  • switch i {
  • switch logicalRowIndex(visibleI, cfg) {
    case 0:
    return cfg.Global.DefaultMode
    case 1:
    @@ -99,18 +211,25 @@
    return cfg.UI.ColorProfile
    case 10:
    return "coming soon"
  • case 11:
  •   return gardenGrowthPaceLabel(cfg)
    
  • case 12:
  •   return gardenSeedRateLabel(cfg)
    
  • case 13:
  •   return gardenOffspringLabel(cfg)
    
    }
    return ""
    }

-func applyConfigChange(i int, cfg *config.Config, dir int) {
+func applyConfigChange(visibleI int, cfg *config.Config, dir int) {
if cfg == nil {
return
}

  • row := configRows[i]
  • row := configRowAt(visibleI, cfg)
  • li := logicalRowIndex(visibleI, cfg)
    switch row.kind {
    case configRowBool:
  •   switch i {
    
  •   switch li {
      case 1:
      	cfg.Global.DisableScan = !cfg.Global.DisableScan
      case 3:
    

@@ -120,7 +239,7 @@
}
case configRowEnum:
opts := row.options

  •   cur := configRowValue(i, cfg)
    
  •   cur := configRowValue(visibleI, cfg)
      idx := 0
      for j, o := range opts {
      	if o == cur {
    

@@ -129,7 +248,7 @@
}
}
idx = (idx + dir + len(opts)) % len(opts)

  •   switch i {
    
  •   switch li {
      case 0:
      	cfg.Global.DefaultMode = opts[idx]
      case 2:
    

@@ -150,6 +269,39 @@
applyRainTickChange(cfg, opts, dir)
case 9:
cfg.UI.ColorProfile = opts[idx]

  •   case 11:
    
  •   	switch opts[idx] {
    
  •   	case "calm":
    
  •   		cfg.UI.GardenGrowthPace = 1.32
    
  •   	case "fast":
    
  •   		cfg.UI.GardenGrowthPace = 0.78
    
  •   	default:
    
  •   		cfg.UI.GardenGrowthPace = 0
    
  •   	}
    
  •   case 12:
    
  •   	switch opts[idx] {
    
  •   	case "rare":
    
  •   		cfg.UI.GardenSeedRate = 0.06
    
  •   	case "often":
    
  •   		cfg.UI.GardenSeedRate = 0.15
    
  •   	default:
    
  •   		cfg.UI.GardenSeedRate = 0
    
  •   	}
    
  •   case 13:
    
  •   	switch opts[idx] {
    
  •   	case "few":
    
  •   		cfg.UI.GardenOffspringMin = 1
    
  •   		cfg.UI.GardenOffspringMax = 2
    
  •   		cfg.UI.GardenOffspringSpread = 2
    
  •   	case "many":
    
  •   		cfg.UI.GardenOffspringMin = 3
    
  •   		cfg.UI.GardenOffspringMax = 4
    
  •   		cfg.UI.GardenOffspringSpread = 5
    
  •   	default:
    
  •   		cfg.UI.GardenOffspringMin = 0
    
  •   		cfg.UI.GardenOffspringMax = 0
    
  •   		cfg.UI.GardenOffspringSpread = 0
    
  •   	}
      }
    

    case configRowComingSoon:
    // reserved
    @@ -207,12 +359,13 @@
    }

    case "down", "j":

  •   if m.configCursor < len(configRows)-1 {
    
  •   if m.configCursor < visibleConfigRowCount(m.cfg)-1 {
      	m.configCursor++
      }
    

    case " ", "right", "l":
    applyConfigChange(m.configCursor, m.cfg, +1)

  •   m.configCursor = clampConfigCursor(m.cfg, m.configCursor)
      if m.cfg != nil {
      	applyColorProfile(m.cfg.UI.ColorProfile)
      }
    

@@ -221,6 +374,7 @@

case "left", "h":
	applyConfigChange(m.configCursor, m.cfg, -1)
  •   m.configCursor = clampConfigCursor(m.cfg, m.configCursor)
      if m.cfg != nil {
      	applyColorProfile(m.cfg.UI.ColorProfile)
      }
    

@@ -251,7 +405,7 @@
rainW := RainDisplayWidth(m.windowWidth)
s.WriteString(m.rainBg.Render())
s.WriteString("\n")

  •   s.WriteString(RenderRainWave(rainW, m.frameIndex, m.rainAnimationMode))
    
  •   s.WriteString(m.renderRainWaveStrip(rainW))
      s.WriteString("\n\n")
    
    }

@@ -273,7 +427,8 @@
valueStyle := lipgloss.NewStyle().Foreground(activeProfile().configValue).Bold(true)
dimStyle := lipgloss.NewStyle().Foreground(activeProfile().configDim)

  • for i, row := range configRows {
  • for i := 0; i < visibleConfigRowCount(m.cfg); i++ {
  •   row := configRowAt(i, m.cfg)
      cur := " "
      if m.configCursor == i {
      	cur = ">"
    

@@ -342,7 +497,13 @@
m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
m.rainAnimationMode = m.cfg.UI.RainAnimationMode
if m.rainBg != nil {

  •   m.rainBg.Mode = m.rainAnimationMode
    
  •   bgW, h := m.rainBg.Width, m.rainBg.Height
    
  •   if m.rainBg.Mode != m.rainAnimationMode {
    
  •   	m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
    
  •   } else {
    
  •   	m.rainBg.Mode = m.rainAnimationMode
    
  •   }
    
  •   m.applyGardenTuning(m.rainBg)
    
    }
    m.showStartupQuote = m.cfg.UI.ShowStartupQuote
    m.startupQuoteBehavior = m.cfg.UI.StartupQuoteBehavior

diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go
--- a/internal/ui/rain_bg.go
+++ b/internal/ui/rain_bg.go
@@ -26,6 +26,38 @@
// flowerStages for advanced mode bottom row: growth over time
var flowerStages = []string{"·", "♦", "✿", "❀"}

+// gardenFlowerAccentHex holds distinct flower-head colors (garden mode).
+var gardenFlowerAccentHex = []string{

  • "#EC407A", // pink
  • "#AB47BC", // purple
  • "#FFCA28", // amber
  • "#FF7043", // deep orange
  • "#BA68C8", // lavender
  • "#F06292", // light pink
  • "#FFD54F", // golden yellow
  • "#7E57C2", // deep purple
  • "#26C6DA", // cyan
  • "#FFEE58", // yellow
  • "#EF5350", // coral
  • "#66BB6A", // green blossom
    +}

+func gardenFlowerForeground(tint int) lipgloss.Color {

  • n := len(gardenFlowerAccentHex)
  • if n == 0 {
  •   return activeProfile().flowerColor
    
  • }
  • u := tint % n
  • if u < 0 {
  •   u += n
    
  • }
  • return lipgloss.Color(gardenFlowerAccentHex[u])
    +}

+func gardenFlowerStyle(tint int) lipgloss.Style {

  • return lipgloss.NewStyle().Foreground(gardenFlowerForeground(tint))
    +}

// RainDrop represents a single falling raindrop particle
type RainDrop struct {
X int
@@ -35,8 +67,238 @@
Age int
MaxAge int
Speed int // move down every Speed frames (1 = every frame, 2 = every other, etc.)

  • IsSeed bool // garden mode: seed vs rain
    }

+// gardenStage drives the lifecycle in garden animation mode.
+const (

  • gardenStageNone = iota
  • gardenStagePlanted
  • gardenStageSprout
  • gardenStageBud
  • gardenStageBloom
  • gardenStageWither
  • gardenStageEternal
    +)

+type gardenPlot struct {

  • stage int
  • moisture int
  • bloomAge int
  • witherAge int
  • maxBloom int // randomized per-bloom duration; computed when entering bloom
  • // flowerTint picks a stable accent from gardenFlowerAccentHex for bloom /
  • // eternal flower heads; set when a seed first plants the column.
  • flowerTint int
    +}

+// GardenTuning controls the pacing and reproductive behavior of garden mode.
+// Zero-valued fields are filled in from DefaultGardenTuning() by ResolveGardenTuning.
+type GardenTuning struct {

  • // SeedSpawnRate is the fraction of new sky drops that fall as seeds (0..1).
  • SeedSpawnRate float64
  • // RainAbsorbExtraChance is the probability that an extra rain drop in the
  • // same frame still counts after the per-column water cap is reached.
  • RainAbsorbExtraChance float64
  • // Moisture thresholds to advance through plant stages.
  • PlantedToSproutMoisture int
  • SproutToBudMoisture int
  • BudToBloomMoisture int
  • // SeedMoistureBoost is the moisture added when a seed lands on an existing plot.
  • SeedMoistureBoost int
  • // Bloom and wither timing (frames). Effective bloom duration is
  • // BloomDurationBase + rand.Intn(BloomDurationJitter).
  • BloomDurationBase int
  • BloomDurationJitter int
  • WitherDuration int
  • // OffspringMin..OffspringMax (inclusive) seeds spawn from each dying plant.
  • // OffspringSpread is the half-width of the X jitter window around the parent.
  • OffspringMin int
  • OffspringMax int
  • OffspringSpread int
    +}

+// DefaultGardenTuning returns the built-in pacing constants chosen so the
+// garden takes its time: most sky pixels are rain, columns advance at most
+// once per frame, blooms linger, and dying plants scatter few nearby seeds.
+func DefaultGardenTuning() GardenTuning {

  • return GardenTuning{
  •   SeedSpawnRate:           0.055,
    
  •   RainAbsorbExtraChance:   0.11,
    
  •   PlantedToSproutMoisture: 16,
    
  •   SproutToBudMoisture:     22,
    
  •   BudToBloomMoisture:      30,
    
  •   SeedMoistureBoost:       1,
    
  •   BloomDurationBase:       85,
    
  •   BloomDurationJitter:     55,
    
  •   WitherDuration:          34,
    
  •   OffspringMin:            1,
    
  •   OffspringMax:            2,
    
  •   OffspringSpread:         2,
    
  • }
    +}

+// ResolveGardenTuning fills any zero-valued field in t with the corresponding
+// default. Callers (e.g. config plumbing) can pass partial structs.
+func ResolveGardenTuning(t GardenTuning) GardenTuning {

  • d := DefaultGardenTuning()
  • if t.SeedSpawnRate <= 0 {
  •   t.SeedSpawnRate = d.SeedSpawnRate
    
  • }
  • if t.RainAbsorbExtraChance <= 0 {
  •   t.RainAbsorbExtraChance = d.RainAbsorbExtraChance
    
  • }
  • if t.PlantedToSproutMoisture <= 0 {
  •   t.PlantedToSproutMoisture = d.PlantedToSproutMoisture
    
  • }
  • if t.SproutToBudMoisture <= 0 {
  •   t.SproutToBudMoisture = d.SproutToBudMoisture
    
  • }
  • if t.BudToBloomMoisture <= 0 {
  •   t.BudToBloomMoisture = d.BudToBloomMoisture
    
  • }
  • if t.SeedMoistureBoost <= 0 {
  •   t.SeedMoistureBoost = d.SeedMoistureBoost
    
  • }
  • if t.BloomDurationBase <= 0 {
  •   t.BloomDurationBase = d.BloomDurationBase
    
  • }
  • if t.BloomDurationJitter <= 0 {
  •   t.BloomDurationJitter = d.BloomDurationJitter
    
  • }
  • if t.WitherDuration <= 0 {
  •   t.WitherDuration = d.WitherDuration
    
  • }
  • if t.OffspringMin <= 0 {
  •   t.OffspringMin = d.OffspringMin
    
  • }
  • if t.OffspringMax <= 0 {
  •   t.OffspringMax = d.OffspringMax
    
  • }
  • if t.OffspringMax < t.OffspringMin {
  •   t.OffspringMax = t.OffspringMin
    
  • }
  • if t.OffspringSpread <= 0 {
  •   t.OffspringSpread = d.OffspringSpread
    
  • }
  • if t.SeedSpawnRate > 1 {
  •   t.SeedSpawnRate = 1
    
  • }
  • if t.RainAbsorbExtraChance > 1 {
  •   t.RainAbsorbExtraChance = 1
    
  • }
  • return t
    +}

+// gardenTargetStormWallSeconds picks a storm-completion time budget from the
+// same TUI presets as config_view (growth pace + seed rate). Used to scale
+// tuning so GardenSunny tends to land near these wall times at a reference
+// width, adjusted by applyGardenStormWallClockScale for tick + width.
+func gardenTargetStormWallSeconds(cfg *config.Config) float64 {

  • if cfg == nil {
  •   return 32
    
  • }
  • p := cfg.UI.GardenGrowthPace
  • s := cfg.UI.GardenSeedRate
  • rareSeed := s > 0 && s < 0.08
  • calm := p >= 1.2
  • oftenSeed := s > 0.12
  • fastPace := p > 0 && p < 0.9
  • switch {
  • case rareSeed && calm:
  •   return 180
    
  • case rareSeed:
  •   return 150
    
  • case calm:
  •   return 60
    
  • case oftenSeed && fastPace:
  •   return 26
    
  • case oftenSeed || fastPace:
  •   return 36
    
  • default:
  •   return 32
    
  • }
    +}

+// applyGardenStormWallClockScale stretches resolved garden pacing so the
+// storm phase (until ~80% visual fill / GardenSunny) tends toward
+// gardenTargetStormWallSeconds, scaled by rain tick (ms/frame) and sqrt(width)
+// so wider terminals get proportionally more frames for the same wall time.
+func applyGardenStormWallClockScale(t *GardenTuning, cfg *config.Config, rainTickMS, gardenWidth int) {

  • if t == nil {
  •   return
    
  • }
  • tick := rainTickMS
  • if tick <= 0 {
  •   tick = config.DefaultUIRainTickMS
    
  • }
  • sec := gardenTargetStormWallSeconds(cfg)
  • w := float64(gardenWidth)
  • if w < 12 {
  •   w = 12
    
  • }
  • const refW = 56.0
  • widthNorm := math.Sqrt(w / refW)
  • targetFrames := sec * (1000.0 / float64(tick)) * widthNorm
  • // Empirical baseline: pre–wall-clock tuning tended to finish the storm in
  • // roughly this many frames at default moisture + seed rates. Ratio maps
  • // desired wall time into multipliers on thresholds and spawn rate.
  • const refFrames = 66.0
  • scale := targetFrames / refFrames
  • if scale < 1.0 {
  •   scale = 1.0
    
  • }
  • // Slightly extra stretch on the path to first bloom; sky seeds are capped
  • // separately, so moisture still needs headroom to feel gradual.
  • const moistureBloomStretch = 1.12
  • scaleMoist := scale * moistureBloomStretch
  • scaleInt := func(v int, m float64) int {
  •   x := int(float64(v)*m + 0.5)
    
  •   if x < 1 {
    
  •   	return 1
    
  •   }
    
  •   return x
    
  • }
  • t.PlantedToSproutMoisture = scaleInt(t.PlantedToSproutMoisture, scaleMoist)
  • t.SproutToBudMoisture = scaleInt(t.SproutToBudMoisture, scaleMoist)
  • t.BudToBloomMoisture = scaleInt(t.BudToBloomMoisture, scaleMoist)
  • t.BloomDurationBase = scaleInt(t.BloomDurationBase, scale)
  • t.BloomDurationJitter = scaleInt(t.BloomDurationJitter, scale)
  • t.WitherDuration = scaleInt(t.WitherDuration, scale)
  • if t.SeedMoistureBoost > 0 {
  •   b := int(float64(t.SeedMoistureBoost)/math.Sqrt(scale) + 0.5)
    
  •   if b < 1 {
    
  •   	b = 1
    
  •   }
    
  •   t.SeedMoistureBoost = b
    
  • }
  • t.SeedSpawnRate /= scale
  • if t.SeedSpawnRate < 0.008 {
  •   t.SeedSpawnRate = 0.008
    
  • }
  • if t.SeedSpawnRate > 0.45 {
  •   t.SeedSpawnRate = 0.45
    
  • }
  • t.RainAbsorbExtraChance /= scale*0.85 + 0.15
  • if t.RainAbsorbExtraChance < 0.02 {
  •   t.RainAbsorbExtraChance = 0.02
    
  • }
  • if t.RainAbsorbExtraChance > 0.55 {
  •   t.RainAbsorbExtraChance = 0.55
    
  • }
    +}

// flowerCell tracks accumulated rainfall at a column for the advanced animation
type flowerCell struct {
drops int // accumulated drop count at this column
@@ -44,13 +306,16 @@

// RainBackground manages the animated rain background
type RainBackground struct {

  • Width int
  • Height int
  • Drops []RainDrop
  • Frame int
  • Mode string // "basic" or "advanced"
  • Flowers []flowerCell
  • CloudRow []string // pre-rendered cloud chars per column
  • Width int
  • Height int
  • Drops []RainDrop
  • Frame int
  • Mode string // "basic" or "advanced"
  • Flowers []flowerCell
  • CloudRow []string // pre-rendered cloud chars per column
  • GardenPlots []gardenPlot
  • GardenSunny bool // garden mode: rain finished, sky cleared
  • Garden GardenTuning // pacing knobs (always resolved to non-zero values)
    }

// NewRainBackground creates a new rain background
@@ -61,15 +326,25 @@
Drops: make([]RainDrop, 0),
Frame: 0,
Mode: mode,

  •   Garden: DefaultGardenTuning(),
    
    }
    if width > 0 {
    rb.Flowers = make([]flowerCell, width)
    rb.CloudRow = rb.buildCloudRow()
    }
  • if width > 0 && mode == config.UIRainAnimationGarden {
  •   rb.GardenPlots = make([]gardenPlot, width)
    
  • }
    rb.Reset()
    return rb
    }

+// SetGardenTuning replaces the active garden tuning. Zero-valued fields in t
+// are resolved to defaults; callers can pass partial structs.
+func (rb *RainBackground) SetGardenTuning(t GardenTuning) {

  • rb.Garden = ResolveGardenTuning(t)
    +}

func (rb *RainBackground) buildCloudRow() []string {
row := make([]string, rb.Width)
for x := 0; x < rb.Width; x++ {
@@ -85,13 +360,21 @@
// Reset reinitializes all drops
func (rb *RainBackground) Reset() {
rb.Drops = make([]RainDrop, 0)

  • rb.GardenSunny = false
  • if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
  •   rb.GardenPlots = make([]gardenPlot, rb.Width)
    
  • }
    startY := 0
  • if rb.Mode == config.UIRainAnimationAdvanced {
  •   startY = 1 // leave top row for clouds
    
  • if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
  •   startY = 1 // leave top row for clouds / sky
    
    }
    targetCount := rb.Width * 2
  • for i := 0; i < targetCount; i++ {
  •   rb.spawnDrop(startY)
    
  • if rb.Mode == config.UIRainAnimationGarden {
  •   rb.spawnGardenMaintainingDrops(startY, targetCount)
    
  • } else {
  •   for i := 0; i < targetCount; i++ {
    
  •   	rb.spawnDrop(startY)
    
  •   }
    
    }
    }

@@ -106,6 +389,7 @@
}
... diff truncated: showing 800 of 1820 lines


</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-206fd568-d609-464e-b1d3-8d2e2b8d0143">here</a>.</sub>
<!-- BUGBOT_AUTOFIX_REVIEW_FOOTNOTE_END -->

<sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 77f6ce80f53cb73338cc5bb714c9cdb6bddb0932. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>

Comment thread internal/ui/repo_selector.go
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/ui/rain_bg.go (1)

538-547: Variable name cap shadows the builtin.

Minor nit: using cap as a local variable shadows Go's built-in cap(). Not currently harmful since the function doesn't call cap(), but defensive against future edits.

♻️ Suggested rename
-	lo := max(1, hi/5)
-	cap := int(0.5 + float64(lo)+(float64(hi-lo))*relief)
-	if cap < 1 {
-		cap = 1
-	}
-	if cap > hi {
-		cap = hi
-	}
-	return cap
+	lo := max(1, hi/5)
+	ceiling := int(0.5 + float64(lo) + float64(hi-lo)*relief)
+	if ceiling < 1 {
+		ceiling = 1
+	}
+	if ceiling > hi {
+		ceiling = hi
+	}
+	return ceiling
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/rain_bg.go` around lines 538 - 547, The local variable named
"cap" in the block that computes capacity (using lo, hi and relief) shadows Go's
built-in cap(), so rename that variable (e.g., to "capacity" or "capVal")
everywhere in this function/block (including assignments and return) to avoid
shadowing and future confusion; ensure you replace all references to "cap" in
this expression and keep the same boundary checks (min 1, max hi) and returned
value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/ui/rain_bg.go`:
- Around line 538-547: The local variable named "cap" in the block that computes
capacity (using lo, hi and relief) shadows Go's built-in cap(), so rename that
variable (e.g., to "capacity" or "capVal") everywhere in this function/block
(including assignments and return) to avoid shadowing and future confusion;
ensure you replace all references to "cap" in this expression and keep the same
boundary checks (min 1, max hi) and returned value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 380335fe-bc23-4295-b9a7-e991f854565d

📥 Commits

Reviewing files that changed from the base of the PR and between 5533a10 and 32dc264.

📒 Files selected for processing (5)
  • internal/config/defaults.go
  • internal/config/types.go
  • internal/ui/rain_bg.go
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
✅ Files skipped from review due to trivial changes (1)
  • internal/config/defaults.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/ui/rain_bg_test.go

Derive PresetAggression from TUI growth/seed/offspring rows, dampen storm
length scaling for aggressive combos, shorten fast+often storm targets,
and relax early sky-seed caps and batch coefficients when aggression is high.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
internal/ui/rain_bg.go (1)

540-540: ⚠️ Potential issue | 🟠 Major

Remove the redundant nil check to unblock lint.

len is defined for nil slices in Go, and this condition is the confirmed S1009 failure.

Proposed fix
-	if rb.GardenPlots == nil || len(rb.GardenPlots) == 0 {
+	if len(rb.GardenPlots) == 0 {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/rain_bg.go` at line 540, The condition checking both nil and
length on rb.GardenPlots is redundant (len works on nil slices); change the if
in the RainBackground code that currently reads "if rb.GardenPlots == nil ||
len(rb.GardenPlots) == 0" to a single length check "if len(rb.GardenPlots) == 0"
so the S1009 lint warning is resolved while preserving the existing branch
behavior; locate the check around the RainBackground/GardenPlots usage and
replace it accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/ui/rain_bg.go`:
- Around line 1156-1179: The glyph styling logic currently picks styles based on
position (k, h) so second flower glyphs render as leaves; after calling
gardenGlyph(g.stage, k, h) capture the returned ch and if ch is a bloom/eternal
flower glyph (e.g., '❀' or '✿') override the chosen st with
gardenFlowerStyle(g.flowerTint) (and keep .Bold(true) for gardenStageEternal top
if desired); update the switch branches (references: gardenGlyph,
gardenFlowerStyle, gardenStageEternal, g.stage, ch) to apply this glyph-based
check so every actual flower glyph receives the flower accent regardless of k
position.
- Around line 765-768: The guard uses a negated conjunction which triggers
QF1001; replace the third clause !(rb.Mode == config.UIRainAnimationGarden &&
rb.GardenSunny) with the equivalent De Morgan form (rb.Mode !=
config.UIRainAnimationGarden || !rb.GardenSunny) so the if condition becomes
rb.Width > 0 && rb.Height > 0 && (rb.Mode != config.UIRainAnimationGarden ||
!rb.GardenSunny); leave the inner logic (including
spawnGardenMaintainingDrops(minY, targetCount)) unchanged to preserve behavior.

---

Duplicate comments:
In `@internal/ui/rain_bg.go`:
- Line 540: The condition checking both nil and length on rb.GardenPlots is
redundant (len works on nil slices); change the if in the RainBackground code
that currently reads "if rb.GardenPlots == nil || len(rb.GardenPlots) == 0" to a
single length check "if len(rb.GardenPlots) == 0" so the S1009 lint warning is
resolved while preserving the existing branch behavior; locate the check around
the RainBackground/GardenPlots usage and replace it accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e4888ae7-410e-4057-b14a-6b3ef5453be3

📥 Commits

Reviewing files that changed from the base of the PR and between 32dc264 and 112daf0.

📒 Files selected for processing (3)
  • internal/ui/rain_bg.go
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go

Comment thread internal/ui/rain_bg.go Outdated
Comment thread internal/ui/rain_bg.go
Apply staticcheck/gocritic cleanups (nil len check, De Morgan spawn guard,
mode switch in Render, tagged k switches in garden overlays, rename cap).
Expire drops after one garden soil interaction so rain and seeds do not
re-trigger every frame. Align README garden_seed_rate with defaults and
make the mature sky-seed throttle test deterministic.
@bschellenberger2600 bschellenberger2600 merged commit 692d726 into main Apr 19, 2026
11 checks passed
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