Skip to content

feat(ui): snow winter scene, rain panel size, and snow fixes#13

Open
bschellenberger2600 wants to merge 7 commits into
mainfrom
feat/ui-snow-rain-mode
Open

feat(ui): snow winter scene, rain panel size, and snow fixes#13
bschellenberger2600 wants to merge 7 commits into
mainfrom
feat/ui-snow-rain-mode

Conversation

@bschellenberger2600
Copy link
Copy Markdown
Member

@bschellenberger2600 bschellenberger2600 commented Apr 19, 2026

Summary

Adds snow rain_animation_mode with a winter strip (flakes, drifting ground snow, cabin, smoke, evergreens, staged snowman), rain_panel_size presets (compact / comfortable / tall) with runtime clamping, and follow-up fixes for stability and polish.

Fixes (second commit)

  • Stack overflow: rainVisible no longer calls layout measurement that re-entered the header; measurement uses rainStripIncludedInLayoutMeasure() while the live view still uses rainVisible().
  • Snow: snow_accumulation_rate (1–8× depth per landing), settings row in snow mode, loader default, tests.
  • Scene: trees placed at init; snowman feet on the row above the ground bank for separation; clearer bold/contrast styling.

Tests

go test ./... (pass)

Docs

README + example TOML updated on the base commit for snow and panel size.


Note

Medium Risk
Moderate UI/config surface-area change: adds a new animation mode with new rendering/state plus new config keys and layout-measurement logic, which could impact TUI sizing or cause regressions on small terminals.

Overview
Adds a new snow rain_animation_mode with a winter scene (snowflakes with accumulating ground, cabin/smoke, trees, and a staged snowman) and integrates it into the TUI renderer and settings.

Introduces ui.rain_panel_size presets (compact/comfortable/tall) with normalization/defaults and runtime clamping so the bordered panels fit short terminals; the TUI now recreates the rain background when mode/width/height change.

Adds ui.snow_accumulation_rate (rounded/clamped 1–8) wired through config loading and the settings menu, and fixes a layout recursion/stack-overflow by separating rain visibility checks from layout measurement. Includes new/expanded tests and README config docs for snow and panel sizing.

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

Summary by CodeRabbit

  • New Features

    • Added "snow" animation with snowfall, ground accumulation, and progressive scene (cabin, trees, snowman).
    • Configurable rain panel sizes: "compact", "comfortable" (default), "tall"; TUI now auto-clamps panel height to fit short terminals and adapts background sizing to window changes.
    • Snow accumulation rate setting controls depth added per flake.
  • Documentation

    • README updated with snow mode behavior, rain_panel_size presets, and example TOML.
  • Tests

    • Added tests for snow accumulation, panel sizing, normalization, and scene rendering.

… canvas

Add snow animation with falling flakes, unbounded ground accumulation,
log cabin with chimney smoke and lit windows, random trees with frost,
and a staged snowman. Introduce rain_panel_size (compact/comfortable/tall)
with terminal clamping via panel height measurement. Default comfortable
(8 rows). Settings TUI and README updated.
- Break rainVisible vs measured header cycle (rainStripIncludedInLayoutMeasure + header gate)\n- Add snow_accumulation_rate (1–8× per landing) and settings TUI row\n- Trees spawn at init; snowman elevated above ground bank; bolder styling and split parens on base
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

Adds a "snow" rain animation mode, configurable rain panel sizing (compact/comfortable/tall), and snow accumulation controls; implements a full snow scene (trees, cabin, snowman, smoke), adjusts rain-panel height clamping/layout measurement, and expands config defaults/tests to cover new settings.

Changes

Cohort / File(s) Summary
Docs
README.md
Documented rain_animation_mode = "snow", rain_panel_size presets, clamping behavior, and example TOML.
Config types & helpers
internal/config/types.go
Added UI.RainPanelSize, UI.SnowAccumulationRate, UIRainAnimationSnow and panel constants; added RainPanelRows, NormalizeRainPanelSize, SnowAccumPerLanding.
Config defaults & loader
internal/config/defaults.go, internal/config/loader.go
Set defaults for RainPanelSize and SnowAccumulationRate; expanded ExampleConfigTOML; normalize ui.rain_panel_size during load.
Config tests
internal/config/config_test.go, internal/config/snow_accum_test.go
Added tests for panel-row mapping, preset normalization, and snow accumulation-per-landing behavior.
UI config view
internal/ui/config_view.go
Added snow-mode rows and "Rain panel size" row, updated row indexing/translation and applyConfigChange to handle new settings; rebuilds rain background when mode/width/height change.
Rain background & snow scene
internal/ui/rain_bg.go, internal/ui/snow_scene.go, internal/ui/rain_bg_test.go
Integrated snow-mode state (ground depths, trees, cabin, snowman, smoke), snowflake spawning/landing accumulation, scene advancement, snow rendering, palette/strip updates, setter SetSnowAccumPerLanding, and tests for dimensions/accumulation/phase.
Repo selector & layout
internal/ui/repo_selector.go, internal/ui/view_layout.go, internal/ui/view_layout_test.go
Clamped rain background height to fit panels, introduced rain-strip inclusion predicate for layout measurement, parameterized header helpers to avoid recursion, and applied snow accumulation tuning when in snow mode.
Minor/formatting
internal/ui/view_layout_test.go (reformat)
Struct field alignment/formatting changes only.

Sequence Diagram

sequenceDiagram
    participant Config as Configuration
    participant Loader as Config Loader
    participant RainBg as RainBackground
    participant SnowScene as SnowScene State
    participant Renderer as Frame Renderer

    Config->>Loader: Load config (rain mode, panel size, accumulation)
    Loader->>Config: Normalize presets (NormalizeRainPanelSize)
    Loader->>RainBg: Initialize NewRainBackground(mode, clampedHeight)
    RainBg->>SnowScene: initSnowScene() allocate ground, trees, cabin, snowman

    loop Each Frame
        RainBg->>RainBg: spawnDrop() (snowflake glyph)
        RainBg->>RainBg: update drop position
        alt drop lands on ground
            RainBg->>SnowScene: SnowGround[x] += accumPerLanding
            RainBg->>SnowScene: snowNoteFlakeLand(x)
        end
        RainBg->>SnowScene: snowAdvanceScene() (frost, trees, snowman, smoke)
        RainBg->>Renderer: paintSnowScene(cells)
        Renderer-->>RainBg: rendered frame
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A flake drifts down on a midnight screen so deep,
It piles and whispers secrets where snowmen sleep.
Trees frost and cabins glow, pixels soft and bright,
The rabbit hops through panels—compact, tall, comfortable—goodnight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.14% 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 captures the main changes: introducing a snow winter scene feature, rain panel size presets, and associated fixes.
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 feat/ui-snow-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.

Fixes golangci-lint staticcheck SA1019 in CI (Go 1.20+).
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: 4

🧹 Nitpick comments (3)
internal/config/config_test.go (1)

30-37: Consider making this test table-driven.

NormalizeRainPanelSize trims whitespace, lowercases, and falls back to comfortable for unknown/empty input — none of which this test exercises. As per coding guidelines ("Prefer table-driven tests for multi-case functions"), extending to a table-driven shape would also improve error messages.

♻️ Suggested refactor
 func TestNormalizeRainPanelSize(t *testing.T) {
-	if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
-		t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
-	}
-	if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
-		t.Errorf("got %q", got)
-	}
+	tests := []struct {
+		in   string
+		want string
+	}{
+		{" TALL ", config.UIRainPanelTall},
+		{"compact", config.UIRainPanelCompact},
+		{"Comfortable", config.UIRainPanelComfortable},
+		{"", config.UIRainPanelComfortable},
+		{"   ", config.UIRainPanelComfortable},
+		{"bogus", config.UIRainPanelComfortable},
+	}
+	for _, tc := range tests {
+		if got := config.NormalizeRainPanelSize(tc.in); got != tc.want {
+			t.Errorf("NormalizeRainPanelSize(%q) = %q, want %q", tc.in, got, tc.want)
+		}
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/config_test.go` around lines 30 - 37, Convert
TestNormalizeRainPanelSize into a table-driven test that iterates over cases for
inputs and expected outputs; include cases exercising trimming (" TALL "),
different casing ("compact"), lowercase, empty string, unknown value to assert
fallback to config.UIRainPanelComfortable, and any other variants; update test
to loop over a slice of structs with fields name/input/expected and use
t.Run(name, func(t *testing.T){ if got := config.NormalizeRainPanelSize(input);
got != expected { t.Fatalf("NormalizeRainPanelSize(%q)= %q, want %q", input,
got, expected) } }); keep references to NormalizeRainPanelSize, UIRainPanelTall,
UIRainPanelCompact, and UIRainPanelComfortable to locate the code.
internal/ui/repo_selector.go (2)

290-294: Stream init skips snow tuning.

Unlike the static ctor (line 213) which calls base.applyGardenTuning(base.rainBg), the stream path only wires garden tuning via SetGardenTuning and never applies SetSnowAccumPerLanding. If RainAnimationMode == "snow", snowAccumPerLanding remains 0 until the first tea.WindowSizeMsg (line 348) runs applyGardenTuning. In practice the first WindowSizeMsg arrives early so the runtime guard at rain_bg.go:788 (if add < 1 { add = 1 }) masks this, but the two initializers drifting apart will bite the next time snow-only tuning is added.

♻️ Proposed alignment
 	rainH := base.clampedRainBackgroundHeight()
 	rainBg := NewRainBackground(bgW, rainH, animMode)
-	rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW))
 	base.rainBg = rainBg
+	base.applyGardenTuning(base.rainBg)
 	return base
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/repo_selector.go` around lines 290 - 294, The stream
initialization creates rainBg with NewRainBackground and calls SetGardenTuning
but never applies snow-specific tuning, leaving snowAccumPerLanding at 0 until
tea.WindowSizeMsg triggers applyGardenTuning; fix this by invoking the same
application path used in the static ctor—after base.rainBg = rainBg call, call
base.applyGardenTuning(base.rainBg) (or explicitly call
rainBg.SetSnowAccumPerLanding(...) using the gardenTuningFromConfig result when
cfg.RainAnimationMode == "snow") so snow tuning is applied immediately (see
symbols: NewRainBackground, SetGardenTuning, applyGardenTuning,
SetSnowAccumPerLanding, rain_bg.go:788, tea.WindowSizeMsg).

747-773: Clamp loop allocates a fresh RainBackground per probe.

clampedRainBackgroundHeight linearly steps from preset down to 3, and each rainPanelFitsHeight call allocates a new RainBackground (plus Flowers/CloudRow/garden state) and runs two binary-searched lipgloss.Height measurements of the rendered panel. This runs on every tea.WindowSizeMsg, and Update's WindowSizeMsg handler itself reconstructs m.rainBg right after. Not a correctness issue, but worth reusing a single probe RainBackground (e.g. mutate its Height/re-Reset) or short-circuiting once the first fitting height is found via binary search rather than linear scan when responsiveness matters on large terminals.

🤖 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/config_view.go`:
- Around line 199-217: The switch in snowAccumLabel incorrectly maps unspecified
values (5 and 7) to "1×"; instead compute n :=
config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) and return a dynamic
label using that value (e.g. format "%d×") so any integer 1–8 is shown
correctly; update snowAccumLabel to handle nil cfg as before, then use
fmt.Sprintf with the computed n to produce the label.

In `@internal/ui/rain_bg_test.go`:
- Around line 298-351: Tests call the deprecated global rand.Seed which triggers
staticcheck SA1019; make RainBackground (and its snow scene) accept an injected
*rand.Rand (add RainBackground.SetRand(r *rand.Rand) and use that RNG in
spawnDrop/spawnDropGarden/snowNoteFlakeLand/snowAdvanceScene instead of
package-level rand) so tests can do rb.SetRand(rand.New(rand.NewSource(42))) for
deterministic behavior; if that refactor is out of scope for this PR, suppress
the linter in the test by replacing the rand.Seed calls with a
`//nolint:staticcheck` comment on the same line to unblock CI.

In `@internal/ui/snow_scene.go`:
- Around line 61-68: The current Snowman placement logic sets rb.SnowmanX to
rb.Width-5 (or falls back left of the cabin) which lets the snowman hat/scarf
overlap the cabin for mid-range widths; change the computation of rb.SnowmanX to
ensure a 3-column buffer to the right of the cabin by taking rb.SnowmanX =
max(rb.SnowCabinLeft + snowCabinW + 3, rb.Width - 5) and then clamp that value
into the valid horizontal bounds (at least 2 and at most rb.Width-3) before
using it; update the branch that sets rb.SnowmanX (and any fallback logic) to
use these max/clamp calculations and reference rb.SnowmanX, rb.SnowCabinLeft,
snowCabinW and rb.Width when making the change.
- Around line 289-297: snowPaintLine misuses the range loop index (byte offset)
causing multi-byte runes to be placed at wrong columns; fix by iterating runes
and maintaining a separate column counter (e.g., col) instead of using the range
index i, so compute x := left + col and call rb.snowPaintCell(cells, x, y,
string(r), st) while incrementing col for each rune; update the loop in
snowPaintLine to use for _, r := range line with a column counter (or iterate
over []rune(line)) so multi-byte characters like '█' and '≋' map to consecutive
terminal cells.

---

Nitpick comments:
In `@internal/config/config_test.go`:
- Around line 30-37: Convert TestNormalizeRainPanelSize into a table-driven test
that iterates over cases for inputs and expected outputs; include cases
exercising trimming (" TALL "), different casing ("compact"), lowercase, empty
string, unknown value to assert fallback to config.UIRainPanelComfortable, and
any other variants; update test to loop over a slice of structs with fields
name/input/expected and use t.Run(name, func(t *testing.T){ if got :=
config.NormalizeRainPanelSize(input); got != expected {
t.Fatalf("NormalizeRainPanelSize(%q)= %q, want %q", input, got, expected) } });
keep references to NormalizeRainPanelSize, UIRainPanelTall, UIRainPanelCompact,
and UIRainPanelComfortable to locate the code.

In `@internal/ui/repo_selector.go`:
- Around line 290-294: The stream initialization creates rainBg with
NewRainBackground and calls SetGardenTuning but never applies snow-specific
tuning, leaving snowAccumPerLanding at 0 until tea.WindowSizeMsg triggers
applyGardenTuning; fix this by invoking the same application path used in the
static ctor—after base.rainBg = rainBg call, call
base.applyGardenTuning(base.rainBg) (or explicitly call
rainBg.SetSnowAccumPerLanding(...) using the gardenTuningFromConfig result when
cfg.RainAnimationMode == "snow") so snow tuning is applied immediately (see
symbols: NewRainBackground, SetGardenTuning, applyGardenTuning,
SetSnowAccumPerLanding, rain_bg.go:788, tea.WindowSizeMsg).
🪄 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: 856334c3-7e2f-40ba-b8a0-a7500388a225

📥 Commits

Reviewing files that changed from the base of the PR and between 692d726 and f99cdf5.

📒 Files selected for processing (13)
  • README.md
  • internal/config/config_test.go
  • internal/config/defaults.go
  • internal/config/loader.go
  • internal/config/snow_accum_test.go
  • internal/config/types.go
  • internal/ui/config_view.go
  • internal/ui/rain_bg.go
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
  • internal/ui/snow_scene.go
  • internal/ui/view_layout.go
  • internal/ui/view_layout_test.go

Comment thread internal/ui/config_view.go
Comment thread internal/ui/rain_bg_test.go
Comment thread internal/ui/snow_scene.go
Comment on lines +61 to +68
if rb.Width > 16 {
rb.SnowmanX = rb.Width - 5
} else {
rb.SnowmanX = rb.SnowCabinLeft - 4
if rb.SnowmanX < 2 {
rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3)
}
}
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

Snowman placement for mid-range widths can land on the cabin.

For Width > 16 the snowman always sits at Width - 5. At modest widths (say Width = 18), SnowCabinLeft = 18/2 - 3 = 6, so the cabin occupies cells 6..12 (snowCabinW = 7). The snowman at x = 13 is immediately adjacent to the cabin's right wall; with snowPaintLine drawing the hat brim/scarf at SnowmanX-2..SnowmanX+2, those pieces overlap the cabin once the snowman finishes building. The snowFootprintFree check is only used for trees, not for the snowman anchor, so nothing else prevents the collision.

Consider computing SnowmanX as max(SnowCabinLeft + snowCabinW + 3, Width - 5) (and clamping back into [2, Width-3]) so the 18..22-ish width range still leaves breathing room between cabin and snowman.

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

In `@internal/ui/snow_scene.go` around lines 61 - 68, The current Snowman
placement logic sets rb.SnowmanX to rb.Width-5 (or falls back left of the cabin)
which lets the snowman hat/scarf overlap the cabin for mid-range widths; change
the computation of rb.SnowmanX to ensure a 3-column buffer to the right of the
cabin by taking rb.SnowmanX = max(rb.SnowCabinLeft + snowCabinW + 3, rb.Width -
5) and then clamp that value into the valid horizontal bounds (at least 2 and at
most rb.Width-3) before using it; update the branch that sets rb.SnowmanX (and
any fallback logic) to use these max/clamp calculations and reference
rb.SnowmanX, rb.SnowCabinLeft, snowCabinW and rb.Width when making the change.

Comment thread internal/ui/snow_scene.go
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.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Multi-byte runes cause wrong cell positions in snowPaintLine
    • snowPaintLine now advances a rune column counter instead of using the range byte index for x, so multi-byte glyphs paint in consecutive cells.
Preview (ad6153eefb)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -357,6 +357,24 @@
 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.
 
+### Snow mode and rain panel size
+
+`rain_animation_mode = "snow"` uses the same animation strip for a winter scene:
+falling snowflakes, snow that keeps piling on the ground, a small log cabin
+with chimney smoke and lit windows, occasional evergreen trees that pick up
+frost, and a snowman that grows in stages (two spheres, then face, pipe, and
+top hat).
+
+`rain_panel_size` controls how many terminal rows the animation canvas uses:
+`compact` (5), `comfortable` (8, default), or `tall` (11). The TUI clamps the
+height automatically so the bordered panel still fits short terminals.
+
+```toml
+[ui]
+rain_animation_mode = "snow"
+rain_panel_size = "comfortable"
+```
+
 ### 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/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,9 +9,39 @@
 	"github.com/git-rain/git-rain/internal/config"
 )
 
+func TestRainPanelRowsPresets(t *testing.T) {
+	tests := []struct {
+		in   string
+		want int
+	}{
+		{config.UIRainPanelCompact, 5},
+		{config.UIRainPanelComfortable, 8},
+		{config.UIRainPanelTall, 11},
+		{"", 8},
+		{"unknown-preset", 8},
+	}
+	for _, tc := range tests {
+		if got := config.RainPanelRows(tc.in); got != tc.want {
+			t.Errorf("RainPanelRows(%q) = %d, want %d", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestNormalizeRainPanelSize(t *testing.T) {
+	if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
+		t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
+	}
+	if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
+		t.Errorf("got %q", got)
+	}
+}
+
 func TestDefaultConfig_Values(t *testing.T) {
 	cfg := config.DefaultConfig()
 
+	if cfg.UI.RainPanelSize != config.UIRainPanelComfortable {
+		t.Errorf("default RainPanelSize = %q, want %q", cfg.UI.RainPanelSize, config.UIRainPanelComfortable)
+	}
 	if cfg.Global.BranchMode != "mainline" {
 		t.Errorf("default BranchMode = %q, want %q", cfg.Global.BranchMode, "mainline")
 	}

diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -37,11 +37,14 @@
 		UI: UIConfig{
 			ShowRainAnimation:       true,
 			RainAnimationMode:       UIRainAnimationBasic,
+			RainPanelSize:           UIRainPanelComfortable,
 			ShowStartupQuote:        true,
 			StartupQuoteBehavior:    UIQuoteBehaviorRefresh,
 			StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec,
 			RainTickMS:              DefaultUIRainTickMS,
 			ColorProfile:            UIColorProfileStorm,
+			// SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding).
+			SnowAccumulationRate: 0,
 		},
 	}
 }
@@ -114,10 +117,14 @@
 show_rain_animation = true
 
 # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
-# then the rain stops and the sun comes out)
+# "matrix" (falling code characters), "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out), or "snow" (winter scene)
 rain_animation_mode = "basic"
 
+# Animation canvas height: "compact" (5 rows), "comfortable" (8), or "tall" (11).
+# Clamped automatically if the terminal is short.
+rain_panel_size = "comfortable"
+
 # Show flavor quotes in the TUI banner
 show_startup_quote = true
 
@@ -133,6 +140,10 @@
 # Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"
 color_profile = "storm"
 
+# --- Snow mode (rain_animation_mode = "snow") -------------------------------
+# Ground depth added per landed flake (1–8). 1 = default; 3 ≈ three times faster piling.
+# snow_accumulation_rate = 1
+
 # --- 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

diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -69,6 +69,8 @@
 		return nil, fmt.Errorf("invalid config: %w", err)
 	}
 
+	cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+
 	return &cfg, nil
 }
 
@@ -92,6 +94,7 @@
 
 	v.SetDefault("ui.show_rain_animation", defaults.UI.ShowRainAnimation)
 	v.SetDefault("ui.rain_animation_mode", defaults.UI.RainAnimationMode)
+	v.SetDefault("ui.rain_panel_size", defaults.UI.RainPanelSize)
 	v.SetDefault("ui.show_startup_quote", defaults.UI.ShowStartupQuote)
 	v.SetDefault("ui.startup_quote_behavior", defaults.UI.StartupQuoteBehavior)
 	v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
@@ -108,6 +111,7 @@
 	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)
+	v.SetDefault("ui.snow_accumulation_rate", defaults.UI.SnowAccumulationRate)
 }
 
 // Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on

diff --git a/internal/config/snow_accum_test.go b/internal/config/snow_accum_test.go
new file mode 100644
--- /dev/null
+++ b/internal/config/snow_accum_test.go
@@ -1,0 +1,25 @@
+package config
+
+import "testing"
+
+func TestSnowAccumPerLanding(t *testing.T) {
+	tests := []struct {
+		rate float64
+		want int
+	}{
+		{0, 1},
+		{-1, 1},
+		{1, 1},
+		{1.4, 1},
+		{1.5, 2},
+		{3, 3},
+		{8, 8},
+		{8.4, 8},
+		{99, 8},
+	}
+	for _, tt := range tests {
+		if got := SnowAccumPerLanding(tt.rate); got != tt.want {
+			t.Errorf("SnowAccumPerLanding(%v) = %d, want %d", tt.rate, got, tt.want)
+		}
+	}
+}

diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -1,6 +1,11 @@
 // Package config defines the git-rain configuration schema and related constants.
 package config
 
+import (
+	"math"
+	"strings"
+)
+
 // Config represents the complete git-rain configuration
 type Config struct {
 	Global GlobalConfig `mapstructure:"global" toml:"global"`
@@ -57,10 +62,15 @@
 	ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"`
 
 	// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-	// "matrix" (falling code glyphs in the same column pattern), or "garden"
-	// (seeds, rain, growth, then sun).
+	// "matrix" (falling code glyphs in the same column pattern), "garden"
+	// (seeds, rain, growth, then sun), or "snow" (winter scene).
 	RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"`
 
+	// RainPanelSize is how tall the animation canvas is in the TUI: "compact",
+	// "comfortable", or "tall". The runtime clamps to the terminal so the panel
+	// still fits (see RainPanelRows).
+	RainPanelSize string `mapstructure:"rain_panel_size" toml:"rain_panel_size"`
+
 	// Show flavor quotes: TUI banner plus CLI motivation lines.
 	ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"`
 
@@ -111,6 +121,11 @@
 	// 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"`
+
+	// SnowAccumulationRate scales how much ground snow depth each landed flake
+	// adds when rain_animation_mode = "snow". 1 = default; 2 ≈ twice as fast.
+	// Values are rounded to a whole number of depth units per landing (1..8).
+	SnowAccumulationRate float64 `mapstructure:"snow_accumulation_rate" toml:"snow_accumulation_rate,omitempty"`
 }
 
 const (
@@ -127,8 +142,38 @@
 	UIRainAnimationAdvanced = "advanced"
 	UIRainAnimationMatrix   = "matrix"
 	UIRainAnimationGarden   = "garden"
+	UIRainAnimationSnow     = "snow"
+
+	UIRainPanelCompact     = "compact"
+	UIRainPanelComfortable = "comfortable"
+	UIRainPanelTall        = "tall"
 )
 
+// RainPanelRows returns the target animation height in terminal rows for a
+// panel size preset. Unknown or empty values use comfortable.
+func RainPanelRows(preset string) int {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return 5
+	case UIRainPanelTall:
+		return 11
+	default:
+		return 8
+	}
+}
+
+// NormalizeRainPanelSize returns a canonical preset name.
+func NormalizeRainPanelSize(preset string) string {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return UIRainPanelCompact
+	case UIRainPanelTall:
+		return UIRainPanelTall
+	default:
+		return UIRainPanelComfortable
+	}
+}
+
 // UIColorProfiles returns valid built-in UI color profile names.
 func UIColorProfiles() []string {
 	return []string{
@@ -139,3 +184,19 @@
 		UIColorProfileSynthwave,
 	}
 }
+
+// SnowAccumPerLanding returns ground depth units added per landed snowflake.
+// rate is cfg.UI.SnowAccumulationRate; 0 or negative means 1. Result is in [1, 8].
+func SnowAccumPerLanding(rate float64) int {
+	if rate <= 0 {
+		return 1
+	}
+	n := int(math.Round(rate))
+	if n < 1 {
+		return 1
+	}
+	if n > 8 {
+		return 8
+	}
+	return n
+}

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
@@ -42,7 +42,13 @@
 		config.UIRainAnimationAdvanced,
 		config.UIRainAnimationMatrix,
 		config.UIRainAnimationGarden,
+		config.UIRainAnimationSnow,
 	}},
+	{label: "Rain panel size", kind: configRowEnum, options: []string{
+		config.UIRainPanelCompact,
+		config.UIRainPanelComfortable,
+		config.UIRainPanelTall,
+	}},
 	{label: "Show flavor quotes", kind: configRowBool},
 	{label: "Flavor quote behavior", kind: configRowEnum, options: []string{
 		config.UIQuoteBehaviorRefresh,
@@ -58,37 +64,57 @@
 	{label: "Custom hex palette", kind: configRowComingSoon},
 }
 
-// Garden settings rows appear in the menu only when rain mode is garden,
-// directly under "Rain animation mode" (see logicalRowIndex).
+// firstModeExtensionVisibleRow is where mode-specific rows are inserted
+// (after "Rain animation mode" and "Rain panel size").
+const firstModeExtensionVisibleRow = 6
+
+// Garden settings rows appear only when rain mode is garden.
 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)
+// Snow settings rows appear only when rain mode is snow.
+var snowSettingsConfigRows = []configRow{
+	{label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "6×", "8×"}},
+}
+
+func modeExtensionRows(cfg *config.Config) []configRow {
+	if cfg == nil {
+		return nil
 	}
-	return 0
+	switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) {
+	case config.UIRainAnimationGarden:
+		return gardenSettingsConfigRows
+	case config.UIRainAnimationSnow:
+		return snowSettingsConfigRows
+	default:
+		return nil
+	}
 }
 
+func modeExtensionRowCount(cfg *config.Config) int {
+	r := modeExtensionRows(cfg)
+	return len(r)
+}
+
 func visibleConfigRowCount(cfg *config.Config) int {
-	return len(configRows) + gardenSettingsRowCount(cfg)
+	return len(configRows) + modeExtensionRowCount(cfg)
 }
 
 // logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
-// or len(configRows)+k for garden-only rows.
+// or len(configRows)+k for mode-extension rows (garden or snow).
 func logicalRowIndex(visibleI int, cfg *config.Config) int {
-	g := gardenSettingsRowCount(cfg)
+	g := modeExtensionRowCount(cfg)
 	if g == 0 {
 		return visibleI
 	}
-	if visibleI < 5 {
+	if visibleI < firstModeExtensionVisibleRow {
 		return visibleI
 	}
-	if visibleI < 5+g {
-		return len(configRows) + (visibleI - 5)
+	if visibleI < firstModeExtensionVisibleRow+g {
+		return len(configRows) + (visibleI - firstModeExtensionVisibleRow)
 	}
 	return visibleI - g
 }
@@ -98,9 +124,10 @@
 	if li < len(configRows) {
 		return configRows[li]
 	}
+	ext := modeExtensionRows(cfg)
 	gi := li - len(configRows)
-	if gi >= 0 && gi < len(gardenSettingsConfigRows) {
-		return gardenSettingsConfigRows[gi]
+	if ext != nil && gi >= 0 && gi < len(ext) {
+		return ext[gi]
 	}
 	return configRows[len(configRows)-1]
 }
@@ -169,6 +196,26 @@
 	return "default"
 }
 
+func snowAccumLabel(cfg *config.Config) string {
+	if cfg == nil {
+		return "1×"
+	}
+	switch config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) {
+	case 2:
+		return "2×"
+	case 3:
+		return "3×"
+	case 4:
+		return "4×"
+	case 6:
+		return "6×"
+	case 8:
+		return "8×"
+	default:
+		return "1×"
+	}
+}
+
 func configRowValue(visibleI int, cfg *config.Config) string {
 	if cfg == nil {
 		return ""
@@ -194,28 +241,33 @@
 		}
 		return cfg.UI.RainAnimationMode
 	case 5:
+		return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+	case 6:
 		if cfg.UI.ShowStartupQuote {
 			return "true"
 		}
 		return "false"
-	case 6:
+	case 7:
 		return cfg.UI.StartupQuoteBehavior
-	case 7:
+	case 8:
 		return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec)
-	case 8:
+	case 9:
 		if cfg.UI.RainTickMS <= 0 {
 			return strconv.Itoa(config.DefaultUIRainTickMS)
 		}
 		return strconv.Itoa(cfg.UI.RainTickMS)
-	case 9:
+	case 10:
 		return cfg.UI.ColorProfile
-	case 10:
+	case 11:
 		return "coming soon"
-	case 11:
+	case 12:
+		if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+			return snowAccumLabel(cfg)
+		}
 		return gardenGrowthPaceLabel(cfg)
-	case 12:
+	case 13:
 		return gardenSeedRateLabel(cfg)
-	case 13:
+	case 14:
 		return gardenOffspringLabel(cfg)
 	}
 	return ""
@@ -234,7 +286,7 @@
 			cfg.Global.DisableScan = !cfg.Global.DisableScan
 		case 3:
 			cfg.UI.ShowRainAnimation = !cfg.UI.ShowRainAnimation
-		case 5:
+		case 6:
 			cfg.UI.ShowStartupQuote = !cfg.UI.ShowStartupQuote
 		}
 	case configRowEnum:
@@ -258,27 +310,46 @@
 			}
 		case 4:
 			cfg.UI.RainAnimationMode = opts[idx]
-		case 6:
+		case 5:
+			cfg.UI.RainPanelSize = opts[idx]
+		case 7:
 			cfg.UI.StartupQuoteBehavior = opts[idx]
-		case 7:
+		case 8:
 			sec, err := strconv.Atoi(opts[idx])
 			if err == nil && sec > 0 {
 				cfg.UI.StartupQuoteIntervalSec = sec
 			}
-		case 8:
+		case 9:
 			applyRainTickChange(cfg, opts, dir)
-		case 9:
+		case 10:
 			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:
+			if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+				switch opts[idx] {
+				case "2×":
+					cfg.UI.SnowAccumulationRate = 2
+				case "3×":
+					cfg.UI.SnowAccumulationRate = 3
+				case "4×":
+					cfg.UI.SnowAccumulationRate = 4
+				case "6×":
+					cfg.UI.SnowAccumulationRate = 6
+				case "8×":
+					cfg.UI.SnowAccumulationRate = 8
+				default:
+					cfg.UI.SnowAccumulationRate = 0
+				}
+			} else {
+				switch opts[idx] {
+				case "calm":
+					cfg.UI.GardenGrowthPace = 1.32
+				case "fast":
+					cfg.UI.GardenGrowthPace = 0.78
+				default:
+					cfg.UI.GardenGrowthPace = 0
+				}
 			}
-		case 12:
+		case 13:
 			switch opts[idx] {
 			case "rare":
 				cfg.UI.GardenSeedRate = 0.06
@@ -287,7 +358,7 @@
 			default:
 				cfg.UI.GardenSeedRate = 0
 			}
-		case 13:
+		case 14:
 			switch opts[idx] {
 			case "few":
 				cfg.UI.GardenOffspringMin = 1
@@ -497,9 +568,10 @@
 	m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
 	m.rainAnimationMode = m.cfg.UI.RainAnimationMode
 	if m.rainBg != nil {
-		bgW, h := m.rainBg.Width, m.rainBg.Height
-		if m.rainBg.Mode != m.rainAnimationMode {
-			m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
+		bgW := resolveRainBackgroundWidth(m.windowWidth)
+		wantH := m.clampedRainBackgroundHeight()
+		if m.rainBg.Mode != m.rainAnimationMode || m.rainBg.Width != bgW || m.rainBg.Height != wantH {
+			m.rainBg = NewRainBackground(bgW, wantH, m.rainAnimationMode)
 		} else {
 			m.rainBg.Mode = m.rainAnimationMode
 		}

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
@@ -364,6 +364,18 @@
 	GardenPlots []gardenPlot
 	GardenSunny bool         // garden mode: rain finished, sky cleared
 	Garden      GardenTuning // pacing knobs (always resolved to non-zero values)
+
+	// Snow mode (winter scene); used only when Mode == UIRainAnimationSnow.
+	SnowGround            []int
+	SnowCabinLeft         int
+	SnowTrees             []snowTree
+	SnowSmoke             []snowSmoke
+	SnowmanPhase          int
+	SnowmanX              int
+	SnowmanBuild          int
+	SnowmanAux            int // frame counter for accessory delays
+	SnowCabinFrost        int
+	snowAccumPerLanding   int // ground depth per landed flake (1..8)
 }
 
 // NewRainBackground creates a new rain background
@@ -393,6 +405,18 @@
 	rb.Garden = ResolveGardenTuning(t)
 }
 
+// SetSnowAccumPerLanding sets how many ground depth units each snowflake adds
+// when it lands (snow mode only). Values are clamped to [1, 8].
+func (rb *RainBackground) SetSnowAccumPerLanding(n int) {
+	if n < 1 {
+		n = 1
+	}
+	if n > 8 {
+		n = 8
+	}
+	rb.snowAccumPerLanding = n
+}
+
 func (rb *RainBackground) buildCloudRow() []string {
 	row := make([]string, rb.Width)
 	for x := 0; x < rb.Width; x++ {
@@ -412,9 +436,12 @@
 	if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
 		rb.GardenPlots = make([]gardenPlot, rb.Width)
 	}
+	if rb.Mode == config.UIRainAnimationSnow && rb.Width > 0 {
+		rb.initSnowScene()
+	}
 	startY := 0
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
-		startY = 1 // leave top row for clouds / sky
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
+		startY = 1 // leave top row for sky / clouds / night
 	}
 	targetCount := rb.Width * 2
 	if rb.Mode == config.UIRainAnimationGarden {
@@ -447,6 +474,10 @@
 			}
 		}
 	}
+	if rb.Mode == config.UIRainAnimationSnow {
+		char = snowflakeChars[rand.Intn(len(snowflakeChars))]
+		speed = 2 + rand.Intn(2)
+	}
 	drop := RainDrop{
 		X:        rand.Intn(rb.Width),
 		Y:        startY,
@@ -663,9 +694,9 @@
 
 	minY := 0
 	maxDropY := rb.Height - 1
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
 		minY = 1
-		maxDropY = rb.Height - 2 // leave bottom row for plants / flowers
+		maxDropY = rb.Height - 2 // leave bottom row for ground / plants / snow pile
 	}
 
 	if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny {
@@ -751,6 +782,16 @@
 			}
 			p.Age = p.MaxAge
 		}
+
+		if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) {
+			add := rb.snowAccumPerLanding
+			if add < 1 {
+				add = 1
+			}
+			rb.SnowGround[p.X] += add
+			rb.snowNoteFlakeLand(p.X)
+			p.Age = p.MaxAge
+		}
 	}
 
 	// Remove dead drops (off screen or expired)
@@ -775,7 +816,7 @@
 	}
 
 	// Periodically refresh cloud row in advanced / garden (storm) mode
-	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden) && rb.Frame%30 == 0 && rb.Width > 0 {
+	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow) && rb.Frame%30 == 0 && rb.Width > 0 {
 		rb.CloudRow = rb.buildCloudRow()
 	}
 
@@ -783,6 +824,10 @@
 		rb.gardenAdvancePlots()
 		rb.gardenMaybeFinishStorm()
 	}
+
+	if rb.Mode == config.UIRainAnimationSnow {
+		rb.snowAdvanceScene()
+	}
 }
 
 // Render returns the rain background as a string
@@ -816,6 +861,8 @@
 	switch rb.Mode {
 	case config.UIRainAnimationGarden:
 		rb.paintGardenOverlays(cells)
+	case config.UIRainAnimationSnow:
+		rb.paintSnowScene(cells)
 	case config.UIRainAnimationAdvanced:
 		// Top row: clouds
 		if len(rb.CloudRow) >= rb.Width {
@@ -1264,6 +1311,24 @@
 		return result.String()
 	}
 
+	if mode == config.UIRainAnimationSnow {
+		night := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F"))
+		snow := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1"))
+		for x := 0; x < width; x++ {
+			phase := float64(frame)*0.03 + float64(x)*0.11
+			ch := "·"
+			if int(phase*3)%4 == 0 {
+				ch = " "
+			} else if int(phase*5)%7 == 0 {
+				ch = snow.Render("*")
+				result.WriteString(ch)
+				continue
+			}
+			result.WriteString(night.Render(ch))
+		}
+		return result.String()
+	}
+
 	if mode == config.UIRainAnimationMatrix {
 		for x := 0; x < width; x++ {
 			phase := float64(frame)*0.075 + float64(x)*0.24
@@ -1321,6 +1386,9 @@
 	if mode == config.UIRainAnimationMatrix {
 		return matrixRainColors
 	}
+	if mode == config.UIRainAnimationSnow {
+		return snowRainColors
+	}
 	if mode == config.UIRainAnimationGarden {
 		return gardenRainColors
 	}
@@ -1367,3 +1435,15 @@
 	lipgloss.Color("#9FA8DA"),
 	lipgloss.Color("#C5CAE9"),
 }
+
+// snowRainColors is a cool snowflake palette (high → light).
+var snowRainColors = []lipgloss.Color{
+	lipgloss.Color("#263238"),
+	lipgloss.Color("#37474F"),
+	lipgloss.Color("#455A64"),
+	lipgloss.Color("#78909C"),
+	lipgloss.Color("#90A4AE"),
+	lipgloss.Color("#B0BEC5"),
+	lipgloss.Color("#CFD8DC"),
+	lipgloss.Color("#ECEFF1"),
+}

diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go
--- a/internal/ui/rain_bg_test.go
+++ b/internal/ui/rain_bg_test.go
@@ -285,3 +285,63 @@
 		t.Fatalf("expected drops cleared, got %d", len(rb.Drops))
 	}
 }
+
+func TestRenderRainWaveSnowWidth(t *testing.T) {
+	const width = 40
+	s := RenderRainWave(width, 3, config.UIRainAnimationSnow, false)
+	if got := lipgloss.Width(s); got != width {
+		t.Fatalf("lipgloss.Width(RenderRainWave snow) = %d, want %d", got, width)
+	}
+}
+
+func TestRainBackgroundSnowRenderLineWidths(t *testing.T) {
+	const w, h = 32, 8
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	for i := 0; i < 50; i++ {
+		rb.Update()
+	}
+	out := rb.Render()
+	lines := strings.Split(out, "\n")
+	if len(lines) != h {
+		t.Fatalf("expected %d lines, got %d", h, len(lines))
+	}
+	for i, line := range lines {
+		if got := lipgloss.Width(line); got != w {
+			t.Fatalf("line %d: lipgloss.Width = %d, want %d\n%q", i, got, w, line)
+		}
+	}
+}
+
+func TestSnowGroundDepthIncreases(t *testing.T) {
+	const w, h = 24, 6
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	rb.SetSnowAccumPerLanding(4)
+	sum0 := 0
+	for _, v := range rb.SnowGround {
+		sum0 += v
+	}
+	for i := 0; i < 120; i++ {
+		rb.Update()
+	}
+	sum1 := 0
+	for _, v := range rb.SnowGround {
+		sum1 += v
+	}
+	if sum1 <= sum0 {
+		t.Fatalf("expected snow ground accumulation, sum0=%d sum1=%d", sum0, sum1)
+	}
+}
+
+func TestSnowmanProgressesWithLandings(t *testing.T) {
+	const w, h = 40, 8
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	rb.SnowmanPhase = snowmanPhaseBaseDot
+	rb.SnowmanBuild = 0
+	for i := 0; i < 30; i++ {
+		rb.snowNoteFlakeLand(rb.SnowmanX)
+	}
+	rb.snowAdvanceScene()
+	if rb.SnowmanPhase == snowmanPhaseBaseDot {
+		t.Fatal("expected snowman phase to advance after landings near anchor")
+	}
+}

diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go
--- a/internal/ui/repo_selector.go
+++ b/internal/ui/repo_selector.go
@@ -186,13 +186,10 @@
 	s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder)
 
 	animMode := config.UIRainAnimationBasic
-	rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode)
... diff truncated: showing 800 of 1559 lines

You can send follow-ups to the cloud agent here.

Comment thread internal/ui/snow_scene.go
cursoragent and others added 2 commits April 19, 2026 19:02
snowPaintLine used the range index as x offset; in Go that index is the
UTF-8 byte position, so multi-byte glyphs (█, ≋, ─) were spaced incorrectly.
Track a rune/column counter for correct cell placement.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Replace fixed width slots with rejection sampling: tree count scales with width and height, minimum trunk spacing scales with width, canopy uses a 3-column footprint check vs cabin/snowman. Add tests for spacing and valid sites.
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: 3

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

61-68: ⚠️ Potential issue | 🟡 Minor

Snowman placement still overlaps the cabin at mid-range widths.

For Width > 16, SnowmanX is unconditionally set to Width - 5. At Width = 18, SnowCabinLeft = 18/2 - 3 = 6, so the cabin spans cells 6..12 (snowCabinW = 7) and SnowmanX = 13 puts the snowman immediately adjacent to the cabin's right wall. Once the hat brim and scarf render at SnowmanX-2..SnowmanX+2 (lines 461/464), they overlap the cabin. snowFootprintFree is only consulted for trees, so nothing else prevents this collision.

🐛 Proposed fix — enforce a buffer to the right of the cabin and clamp into canvas bounds
-	if rb.Width > 16 {
-		rb.SnowmanX = rb.Width - 5
-	} else {
-		rb.SnowmanX = rb.SnowCabinLeft - 4
-		if rb.SnowmanX < 2 {
-			rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3)
-		}
-	}
+	if rb.Width > 16 {
+		rb.SnowmanX = max(rb.SnowCabinLeft+snowCabinW+3, rb.Width-5)
+		if rb.SnowmanX > rb.Width-3 {
+			rb.SnowmanX = rb.Width - 3
+		}
+	} else {
+		rb.SnowmanX = rb.SnowCabinLeft - 4
+		if rb.SnowmanX < 2 {
+			rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/snow_scene.go` around lines 61 - 68, The SnowmanX assignment for
the Width>16 branch can place the snowman so its hat/scarf
(SnowmanX-2..SnowmanX+2) overlap the cabin; modify the Width>16 branch (where
SnowmanX is set to rb.Width-5) to enforce a right-side buffer from the cabin by
computing cabinRight := rb.SnowCabinLeft + snowCabinW - 1 and then set
rb.SnowmanX = clamp(rb.Width-5, cabinRight+buffer, rb.Width-2) (pick buffer = 3)
so the snowman is moved at least `buffer` cells to the right of the cabin and
still clamped inside the canvas; keep the existing fallback logic for small
widths intact and do not change snowFootprintFree usage.
🧹 Nitpick comments (1)
internal/ui/snow_scene.go (1)

300-319: Hoist per-frame style construction to package-level vars (lines 344–363).

paintSnowScene allocates ~19 fresh lipgloss.Style values every frame via lipgloss.NewStyle().Foreground(...). These are pure constants — they don't depend on frame state or dimensions. Declaring them once at package scope (e.g. var snowNightStyle = lipgloss.NewStyle().Foreground(...)) removes these allocations from the render hot path and matches how other parts of the codebase cache style values.

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

In `@internal/ui/snow_scene.go` around lines 300 - 319, The paintSnowScene
function currently allocates ~19 lipgloss.Style instances each frame (variables
night, star, wood, woodHi, win, ever, everFrost, snowBall, snowHi, snowParen,
coal, nose, smokeSt, hatSt, frost); hoist these constant style constructions to
package-level vars (e.g. define corresponding var nightStyle =
lipgloss.NewStyle().Foreground(...) etc.) so they are created once at init time,
then replace the local declarations in paintSnowScene with references to those
package-level variables to remove per-frame allocations.
🤖 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/snow_scene.go`:
- Around line 460-471: The scarf is being drawn above the face because scarfY is
set to feetY - 3 (above faceY = feetY - 2); change the scarf row to sit below
the face by setting scarfY to faceY + 1 (or feetY - 1) where scarfY is computed,
and keep the existing rb.snowPaintLine call that uses scarfY so the scarf
renders at the neck between head and body instead of above the face (update the
variable assignment near the snowman layout code that defines scarfY).
- Around line 398-412: The loop in snow_scene.go repeatedly calls
lipgloss.NewStyle() per cell; hoist the three depth styles (e.g.,
sLight="#E3F2FD", sMid="#BBDEFB", sDeep="#90CAF9") and the faint variant once
outside the for loop and pick the appropriate precomputed style inside it
instead of allocating new ones each iteration; update the branch that sets st
and the faint usage (st.Faint(true)) to use the precomputed faint-style
variable, keeping rb.SnowGround, snowGroundGlyph and rb.snowPaintCell calls
unchanged.
- Around line 74-101: In snowSpawnInitialTrees the slots slice can contain
duplicate or too-close x positions and snowFootprintFree only checks
cabin/snowman, so dedupe and enforce spacing before appending: compute a unique,
sorted list of candidate x positions from slots, then for each candidate check
snowFootprintFree(x,1) AND ensure no existing rb.SnowTrees (including ones just
appended in this call) have abs(tree.x - x) < MIN_GAP (choose MIN_GAP=2 or 3),
and skip candidates that are outside safe bounds or violate the gap; finally
append only when both checks pass (update references to snowSpawnInitialTrees,
slots, snowFootprintFree, rb.SnowTrees, and the snowTree struct).

---

Duplicate comments:
In `@internal/ui/snow_scene.go`:
- Around line 61-68: The SnowmanX assignment for the Width>16 branch can place
the snowman so its hat/scarf (SnowmanX-2..SnowmanX+2) overlap the cabin; modify
the Width>16 branch (where SnowmanX is set to rb.Width-5) to enforce a
right-side buffer from the cabin by computing cabinRight := rb.SnowCabinLeft +
snowCabinW - 1 and then set rb.SnowmanX = clamp(rb.Width-5, cabinRight+buffer,
rb.Width-2) (pick buffer = 3) so the snowman is moved at least `buffer` cells to
the right of the cabin and still clamped inside the canvas; keep the existing
fallback logic for small widths intact and do not change snowFootprintFree
usage.

---

Nitpick comments:
In `@internal/ui/snow_scene.go`:
- Around line 300-319: The paintSnowScene function currently allocates ~19
lipgloss.Style instances each frame (variables night, star, wood, woodHi, win,
ever, everFrost, snowBall, snowHi, snowParen, coal, nose, smokeSt, hatSt,
frost); hoist these constant style constructions to package-level vars (e.g.
define corresponding var nightStyle = lipgloss.NewStyle().Foreground(...) etc.)
so they are created once at init time, then replace the local declarations in
paintSnowScene with references to those package-level variables to remove
per-frame allocations.
🪄 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: 7ae41f14-58d5-4b83-94e3-926e6022a691

📥 Commits

Reviewing files that changed from the base of the PR and between f99cdf5 and ad6153e.

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

Comment thread internal/ui/snow_scene.go
Comment thread internal/ui/snow_scene.go
Comment on lines +398 to +412
for x := 0; x < rb.Width && x < len(rb.SnowGround); x++ {
d := rb.SnowGround[x]
g := snowGroundGlyph(d)
st := lipgloss.NewStyle().Foreground(lipgloss.Color("#E3F2FD"))
if d >= 18 {
st = lipgloss.NewStyle().Foreground(lipgloss.Color("#BBDEFB"))
}
if d >= 40 {
st = lipgloss.NewStyle().Foreground(lipgloss.Color("#90CAF9"))
}
rb.snowPaintCell(cells, x, gy, g, st)
if d >= 55 && gy-1 >= 1 {
rb.snowPaintCell(cells, x, gy-1, "░", st.Faint(true))
}
}
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

Per-cell lipgloss.NewStyle() allocation in the ground-snow loop.

For every column of every frame you allocate up to three fresh lipgloss.Style values (#E3F2FD, #BBDEFB, #90CAF9), plus another st.Faint(true) (line 410) copy for the second-row glyph. On an 80-wide terminal at typical frame rates this is thousands of throwaway styles/sec for the ground bank alone — easy to avoid by hoisting the three styles once and picking among them.

♻️ Proposed fix — precompute the three depth styles (and the faint variant) outside the loop
-	// Ground snow
-	gy := rb.Height - 1
-	for x := 0; x < rb.Width && x < len(rb.SnowGround); x++ {
-		d := rb.SnowGround[x]
-		g := snowGroundGlyph(d)
-		st := lipgloss.NewStyle().Foreground(lipgloss.Color("#E3F2FD"))
-		if d >= 18 {
-			st = lipgloss.NewStyle().Foreground(lipgloss.Color("#BBDEFB"))
-		}
-		if d >= 40 {
-			st = lipgloss.NewStyle().Foreground(lipgloss.Color("#90CAF9"))
-		}
-		rb.snowPaintCell(cells, x, gy, g, st)
-		if d >= 55 && gy-1 >= 1 {
-			rb.snowPaintCell(cells, x, gy-1, "░", st.Faint(true))
-		}
-	}
+	// Ground snow (styles precomputed to avoid per-cell allocation)
+	groundLight := lipgloss.NewStyle().Foreground(lipgloss.Color("#E3F2FD"))
+	groundMed := lipgloss.NewStyle().Foreground(lipgloss.Color("#BBDEFB"))
+	groundDeep := lipgloss.NewStyle().Foreground(lipgloss.Color("#90CAF9"))
+	groundLightF := groundLight.Faint(true)
+	groundMedF := groundMed.Faint(true)
+	groundDeepF := groundDeep.Faint(true)
+	gy := rb.Height - 1
+	for x := 0; x < rb.Width && x < len(rb.SnowGround); x++ {
+		d := rb.SnowGround[x]
+		st, stF := groundLight, groundLightF
+		if d >= 18 {
+			st, stF = groundMed, groundMedF
+		}
+		if d >= 40 {
+			st, stF = groundDeep, groundDeepF
+		}
+		rb.snowPaintCell(cells, x, gy, snowGroundGlyph(d), st)
+		if d >= 55 && gy-1 >= 1 {
+			rb.snowPaintCell(cells, x, gy-1, "░", stF)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/snow_scene.go` around lines 398 - 412, The loop in snow_scene.go
repeatedly calls lipgloss.NewStyle() per cell; hoist the three depth styles
(e.g., sLight="#E3F2FD", sMid="#BBDEFB", sDeep="#90CAF9") and the faint variant
once outside the for loop and pick the appropriate precomputed style inside it
instead of allocating new ones each iteration; update the branch that sets st
and the faint usage (st.Faint(true)) to use the precomputed faint-style
variable, keeping rb.SnowGround, snowGroundGlyph and rb.snowPaintCell calls
unchanged.

Comment thread internal/ui/snow_scene.go
Comment on lines +460 to +471
if rb.SnowmanPhase >= snowmanPhaseHat && scarfY >= 1 {
rb.snowPaintLine(cells, rb.SnowmanX-2, scarfY, "≋≋≋", scarfSt)
}
if rb.SnowmanPhase >= snowmanPhaseHat && hatBrimY >= 1 {
rb.snowPaintLine(cells, rb.SnowmanX-2, hatBrimY, "───", hatSt)
}
if rb.SnowmanPhase >= snowmanPhaseHat && hatTopY >= 1 {
rb.snowPaintCell(cells, rb.SnowmanX, hatTopY, "█", hatSt)
if hatTopY-1 >= 1 {
rb.snowPaintCell(cells, rb.SnowmanX, hatTopY-1, "●", hatSt.Foreground(lipgloss.Color("#B71C1C")))
}
}
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

Scarf is rendered above the face, not below it.

scarfY = feetY - 3 is one row above faceY = feetY - 2, so the snowman's "scarf" sits between the face and the hat brim (which is even higher at feetY - 4). Anatomically scarves sit at the neck (between head and body); as drawn, the hat brim at hatBrimY = feetY - 4 and the scarf at feetY - 3 stack directly above the face — visually the "scarf" reads more like a hat band.

If this is the intended stylization, disregard; otherwise consider swapping the rows so scarf sits at the neck (either between faceY and bellyY, or overlaid on the bellyY "head" row depending on how you want the three-ball stack to read).

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

In `@internal/ui/snow_scene.go` around lines 460 - 471, The scarf is being drawn
above the face because scarfY is set to feetY - 3 (above faceY = feetY - 2);
change the scarf row to sit below the face by setting scarfY to faceY + 1 (or
feetY - 1) where scarfY is computed, and keep the existing rb.snowPaintLine call
that uses scarfY so the scarf renders at the neck between head and body instead
of above the face (update the variable assignment near the snowman layout code
that defines scarfY).

Comment thread internal/ui/snow_scene.go Outdated
Comment thread internal/ui/config_view.go
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
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 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Snow accumulation rate not applied at stream startup
    • NewRepoSelectorModelStream now assigns the rain background and calls applyGardenTuning so snow accumulation rate is applied on startup, not only after WindowSizeMsg.
  • ✅ Fixed: Trees placed at snowman's future position during init
    • snowFootprintFree now treats the snowman reservation as active during phase none when Width >= 18, matching when the snowman will spawn so initial trees avoid that band.
Preview (639adee692)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -357,6 +357,24 @@
 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.
 
+### Snow mode and rain panel size
+
+`rain_animation_mode = "snow"` uses the same animation strip for a winter scene:
+falling snowflakes, snow that keeps piling on the ground, a small log cabin
+with chimney smoke and lit windows, occasional evergreen trees that pick up
+frost, and a snowman that grows in stages (two spheres, then face, pipe, and
+top hat).
+
+`rain_panel_size` controls how many terminal rows the animation canvas uses:
+`compact` (5), `comfortable` (8, default), or `tall` (11). The TUI clamps the
+height automatically so the bordered panel still fits short terminals.
+
+```toml
+[ui]
+rain_animation_mode = "snow"
+rain_panel_size = "comfortable"
+```
+
 ### 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/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,9 +9,39 @@
 	"github.com/git-rain/git-rain/internal/config"
 )
 
+func TestRainPanelRowsPresets(t *testing.T) {
+	tests := []struct {
+		in   string
+		want int
+	}{
+		{config.UIRainPanelCompact, 5},
+		{config.UIRainPanelComfortable, 8},
+		{config.UIRainPanelTall, 11},
+		{"", 8},
+		{"unknown-preset", 8},
+	}
+	for _, tc := range tests {
+		if got := config.RainPanelRows(tc.in); got != tc.want {
+			t.Errorf("RainPanelRows(%q) = %d, want %d", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestNormalizeRainPanelSize(t *testing.T) {
+	if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
+		t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
+	}
+	if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
+		t.Errorf("got %q", got)
+	}
+}
+
 func TestDefaultConfig_Values(t *testing.T) {
 	cfg := config.DefaultConfig()
 
+	if cfg.UI.RainPanelSize != config.UIRainPanelComfortable {
+		t.Errorf("default RainPanelSize = %q, want %q", cfg.UI.RainPanelSize, config.UIRainPanelComfortable)
+	}
 	if cfg.Global.BranchMode != "mainline" {
 		t.Errorf("default BranchMode = %q, want %q", cfg.Global.BranchMode, "mainline")
 	}

diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -37,11 +37,14 @@
 		UI: UIConfig{
 			ShowRainAnimation:       true,
 			RainAnimationMode:       UIRainAnimationBasic,
+			RainPanelSize:           UIRainPanelComfortable,
 			ShowStartupQuote:        true,
 			StartupQuoteBehavior:    UIQuoteBehaviorRefresh,
 			StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec,
 			RainTickMS:              DefaultUIRainTickMS,
 			ColorProfile:            UIColorProfileStorm,
+			// SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding).
+			SnowAccumulationRate: 0,
 		},
 	}
 }
@@ -114,10 +117,14 @@
 show_rain_animation = true
 
 # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
-# then the rain stops and the sun comes out)
+# "matrix" (falling code characters), "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out), or "snow" (winter scene)
 rain_animation_mode = "basic"
 
+# Animation canvas height: "compact" (5 rows), "comfortable" (8), or "tall" (11).
+# Clamped automatically if the terminal is short.
+rain_panel_size = "comfortable"
+
 # Show flavor quotes in the TUI banner
 show_startup_quote = true
 
@@ -133,6 +140,10 @@
 # Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"
 color_profile = "storm"
 
+# --- Snow mode (rain_animation_mode = "snow") -------------------------------
+# Ground depth added per landed flake (1–8). 1 = default; 3 ≈ three times faster piling.
+# snow_accumulation_rate = 1
+
 # --- 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

diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -69,6 +69,8 @@
 		return nil, fmt.Errorf("invalid config: %w", err)
 	}
 
+	cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+
 	return &cfg, nil
 }
 
@@ -92,6 +94,7 @@
 
 	v.SetDefault("ui.show_rain_animation", defaults.UI.ShowRainAnimation)
 	v.SetDefault("ui.rain_animation_mode", defaults.UI.RainAnimationMode)
+	v.SetDefault("ui.rain_panel_size", defaults.UI.RainPanelSize)
 	v.SetDefault("ui.show_startup_quote", defaults.UI.ShowStartupQuote)
 	v.SetDefault("ui.startup_quote_behavior", defaults.UI.StartupQuoteBehavior)
 	v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
@@ -108,6 +111,7 @@
 	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)
+	v.SetDefault("ui.snow_accumulation_rate", defaults.UI.SnowAccumulationRate)
 }
 
 // Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on

diff --git a/internal/config/snow_accum_test.go b/internal/config/snow_accum_test.go
new file mode 100644
--- /dev/null
+++ b/internal/config/snow_accum_test.go
@@ -1,0 +1,25 @@
+package config
+
+import "testing"
+
+func TestSnowAccumPerLanding(t *testing.T) {
+	tests := []struct {
+		rate float64
+		want int
+	}{
+		{0, 1},
+		{-1, 1},
+		{1, 1},
+		{1.4, 1},
+		{1.5, 2},
+		{3, 3},
+		{8, 8},
+		{8.4, 8},
+		{99, 8},
+	}
+	for _, tt := range tests {
+		if got := SnowAccumPerLanding(tt.rate); got != tt.want {
+			t.Errorf("SnowAccumPerLanding(%v) = %d, want %d", tt.rate, got, tt.want)
+		}
+	}
+}

diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -1,6 +1,11 @@
 // Package config defines the git-rain configuration schema and related constants.
 package config
 
+import (
+	"math"
+	"strings"
+)
+
 // Config represents the complete git-rain configuration
 type Config struct {
 	Global GlobalConfig `mapstructure:"global" toml:"global"`
@@ -57,10 +62,15 @@
 	ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"`
 
 	// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-	// "matrix" (falling code glyphs in the same column pattern), or "garden"
-	// (seeds, rain, growth, then sun).
+	// "matrix" (falling code glyphs in the same column pattern), "garden"
+	// (seeds, rain, growth, then sun), or "snow" (winter scene).
 	RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"`
 
+	// RainPanelSize is how tall the animation canvas is in the TUI: "compact",
+	// "comfortable", or "tall". The runtime clamps to the terminal so the panel
+	// still fits (see RainPanelRows).
+	RainPanelSize string `mapstructure:"rain_panel_size" toml:"rain_panel_size"`
+
 	// Show flavor quotes: TUI banner plus CLI motivation lines.
 	ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"`
 
@@ -111,6 +121,11 @@
 	// 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"`
+
+	// SnowAccumulationRate scales how much ground snow depth each landed flake
+	// adds when rain_animation_mode = "snow". 1 = default; 2 ≈ twice as fast.
+	// Values are rounded to a whole number of depth units per landing (1..8).
+	SnowAccumulationRate float64 `mapstructure:"snow_accumulation_rate" toml:"snow_accumulation_rate,omitempty"`
 }
 
 const (
@@ -127,8 +142,38 @@
 	UIRainAnimationAdvanced = "advanced"
 	UIRainAnimationMatrix   = "matrix"
 	UIRainAnimationGarden   = "garden"
+	UIRainAnimationSnow     = "snow"
+
+	UIRainPanelCompact     = "compact"
+	UIRainPanelComfortable = "comfortable"
+	UIRainPanelTall        = "tall"
 )
 
+// RainPanelRows returns the target animation height in terminal rows for a
+// panel size preset. Unknown or empty values use comfortable.
+func RainPanelRows(preset string) int {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return 5
+	case UIRainPanelTall:
+		return 11
+	default:
+		return 8
+	}
+}
+
+// NormalizeRainPanelSize returns a canonical preset name.
+func NormalizeRainPanelSize(preset string) string {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return UIRainPanelCompact
+	case UIRainPanelTall:
+		return UIRainPanelTall
+	default:
+		return UIRainPanelComfortable
+	}
+}
+
 // UIColorProfiles returns valid built-in UI color profile names.
 func UIColorProfiles() []string {
 	return []string{
@@ -139,3 +184,19 @@
 		UIColorProfileSynthwave,
 	}
 }
+
+// SnowAccumPerLanding returns ground depth units added per landed snowflake.
+// rate is cfg.UI.SnowAccumulationRate; 0 or negative means 1. Result is in [1, 8].
+func SnowAccumPerLanding(rate float64) int {
+	if rate <= 0 {
+		return 1
+	}
+	n := int(math.Round(rate))
+	if n < 1 {
+		return 1
+	}
+	if n > 8 {
+		return 8
+	}
+	return n
+}

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
@@ -42,7 +42,13 @@
 		config.UIRainAnimationAdvanced,
 		config.UIRainAnimationMatrix,
 		config.UIRainAnimationGarden,
+		config.UIRainAnimationSnow,
 	}},
+	{label: "Rain panel size", kind: configRowEnum, options: []string{
+		config.UIRainPanelCompact,
+		config.UIRainPanelComfortable,
+		config.UIRainPanelTall,
+	}},
 	{label: "Show flavor quotes", kind: configRowBool},
 	{label: "Flavor quote behavior", kind: configRowEnum, options: []string{
 		config.UIQuoteBehaviorRefresh,
@@ -58,37 +64,57 @@
 	{label: "Custom hex palette", kind: configRowComingSoon},
 }
 
-// Garden settings rows appear in the menu only when rain mode is garden,
-// directly under "Rain animation mode" (see logicalRowIndex).
+// firstModeExtensionVisibleRow is where mode-specific rows are inserted
+// (after "Rain animation mode" and "Rain panel size").
+const firstModeExtensionVisibleRow = 6
+
+// Garden settings rows appear only when rain mode is garden.
 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)
+// Snow settings rows appear only when rain mode is snow.
+var snowSettingsConfigRows = []configRow{
+	{label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "5×", "6×", "7×", "8×"}},
+}
+
+func modeExtensionRows(cfg *config.Config) []configRow {
+	if cfg == nil {
+		return nil
 	}
-	return 0
+	switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) {
+	case config.UIRainAnimationGarden:
+		return gardenSettingsConfigRows
+	case config.UIRainAnimationSnow:
+		return snowSettingsConfigRows
+	default:
+		return nil
+	}
 }
 
+func modeExtensionRowCount(cfg *config.Config) int {
+	r := modeExtensionRows(cfg)
+	return len(r)
+}
+
 func visibleConfigRowCount(cfg *config.Config) int {
-	return len(configRows) + gardenSettingsRowCount(cfg)
+	return len(configRows) + modeExtensionRowCount(cfg)
 }
 
 // logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
-// or len(configRows)+k for garden-only rows.
+// or len(configRows)+k for mode-extension rows (garden or snow).
 func logicalRowIndex(visibleI int, cfg *config.Config) int {
-	g := gardenSettingsRowCount(cfg)
+	g := modeExtensionRowCount(cfg)
 	if g == 0 {
 		return visibleI
 	}
-	if visibleI < 5 {
+	if visibleI < firstModeExtensionVisibleRow {
 		return visibleI
 	}
-	if visibleI < 5+g {
-		return len(configRows) + (visibleI - 5)
+	if visibleI < firstModeExtensionVisibleRow+g {
+		return len(configRows) + (visibleI - firstModeExtensionVisibleRow)
 	}
 	return visibleI - g
 }
@@ -98,9 +124,10 @@
 	if li < len(configRows) {
 		return configRows[li]
 	}
+	ext := modeExtensionRows(cfg)
 	gi := li - len(configRows)
-	if gi >= 0 && gi < len(gardenSettingsConfigRows) {
-		return gardenSettingsConfigRows[gi]
+	if ext != nil && gi >= 0 && gi < len(ext) {
+		return ext[gi]
 	}
 	return configRows[len(configRows)-1]
 }
@@ -169,6 +196,14 @@
 	return "default"
 }
 
+func snowAccumLabel(cfg *config.Config) string {
+	if cfg == nil {
+		return "1×"
+	}
+	n := config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate)
+	return fmt.Sprintf("%d×", n)
+}
+
 func configRowValue(visibleI int, cfg *config.Config) string {
 	if cfg == nil {
 		return ""
@@ -194,28 +229,33 @@
 		}
 		return cfg.UI.RainAnimationMode
 	case 5:
+		return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+	case 6:
 		if cfg.UI.ShowStartupQuote {
 			return "true"
 		}
 		return "false"
-	case 6:
+	case 7:
 		return cfg.UI.StartupQuoteBehavior
-	case 7:
+	case 8:
 		return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec)
-	case 8:
+	case 9:
 		if cfg.UI.RainTickMS <= 0 {
 			return strconv.Itoa(config.DefaultUIRainTickMS)
 		}
 		return strconv.Itoa(cfg.UI.RainTickMS)
-	case 9:
+	case 10:
 		return cfg.UI.ColorProfile
-	case 10:
+	case 11:
 		return "coming soon"
-	case 11:
+	case 12:
+		if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+			return snowAccumLabel(cfg)
+		}
 		return gardenGrowthPaceLabel(cfg)
-	case 12:
+	case 13:
 		return gardenSeedRateLabel(cfg)
-	case 13:
+	case 14:
 		return gardenOffspringLabel(cfg)
 	}
 	return ""
@@ -234,7 +274,7 @@
 			cfg.Global.DisableScan = !cfg.Global.DisableScan
 		case 3:
 			cfg.UI.ShowRainAnimation = !cfg.UI.ShowRainAnimation
-		case 5:
+		case 6:
 			cfg.UI.ShowStartupQuote = !cfg.UI.ShowStartupQuote
 		}
 	case configRowEnum:
@@ -258,27 +298,50 @@
 			}
 		case 4:
 			cfg.UI.RainAnimationMode = opts[idx]
-		case 6:
+		case 5:
+			cfg.UI.RainPanelSize = opts[idx]
+		case 7:
 			cfg.UI.StartupQuoteBehavior = opts[idx]
-		case 7:
+		case 8:
 			sec, err := strconv.Atoi(opts[idx])
 			if err == nil && sec > 0 {
 				cfg.UI.StartupQuoteIntervalSec = sec
 			}
-		case 8:
+		case 9:
 			applyRainTickChange(cfg, opts, dir)
-		case 9:
+		case 10:
 			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:
+			if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+				switch opts[idx] {
+				case "2×":
+					cfg.UI.SnowAccumulationRate = 2
+				case "3×":
+					cfg.UI.SnowAccumulationRate = 3
+				case "4×":
+					cfg.UI.SnowAccumulationRate = 4
+				case "5×":
+					cfg.UI.SnowAccumulationRate = 5
+				case "6×":
+					cfg.UI.SnowAccumulationRate = 6
+				case "7×":
+					cfg.UI.SnowAccumulationRate = 7
+				case "8×":
+					cfg.UI.SnowAccumulationRate = 8
+				default:
+					cfg.UI.SnowAccumulationRate = 0
+				}
+			} else {
+				switch opts[idx] {
+				case "calm":
+					cfg.UI.GardenGrowthPace = 1.32
+				case "fast":
+					cfg.UI.GardenGrowthPace = 0.78
+				default:
+					cfg.UI.GardenGrowthPace = 0
+				}
 			}
-		case 12:
+		case 13:
 			switch opts[idx] {
 			case "rare":
 				cfg.UI.GardenSeedRate = 0.06
@@ -287,7 +350,7 @@
 			default:
 				cfg.UI.GardenSeedRate = 0
 			}
-		case 13:
+		case 14:
 			switch opts[idx] {
 			case "few":
 				cfg.UI.GardenOffspringMin = 1
@@ -497,9 +560,10 @@
 	m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
 	m.rainAnimationMode = m.cfg.UI.RainAnimationMode
 	if m.rainBg != nil {
-		bgW, h := m.rainBg.Width, m.rainBg.Height
-		if m.rainBg.Mode != m.rainAnimationMode {
-			m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
+		bgW := resolveRainBackgroundWidth(m.windowWidth)
+		wantH := m.clampedRainBackgroundHeight()
+		if m.rainBg.Mode != m.rainAnimationMode || m.rainBg.Width != bgW || m.rainBg.Height != wantH {
+			m.rainBg = NewRainBackground(bgW, wantH, m.rainAnimationMode)
 		} else {
 			m.rainBg.Mode = m.rainAnimationMode
 		}

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
@@ -364,6 +364,18 @@
 	GardenPlots []gardenPlot
 	GardenSunny bool         // garden mode: rain finished, sky cleared
 	Garden      GardenTuning // pacing knobs (always resolved to non-zero values)
+
+	// Snow mode (winter scene); used only when Mode == UIRainAnimationSnow.
+	SnowGround            []int
+	SnowCabinLeft         int
+	SnowTrees             []snowTree
+	SnowSmoke             []snowSmoke
+	SnowmanPhase          int
+	SnowmanX              int
+	SnowmanBuild          int
+	SnowmanAux            int // frame counter for accessory delays
+	SnowCabinFrost        int
+	snowAccumPerLanding   int // ground depth per landed flake (1..8)
 }
 
 // NewRainBackground creates a new rain background
@@ -393,6 +405,18 @@
 	rb.Garden = ResolveGardenTuning(t)
 }
 
+// SetSnowAccumPerLanding sets how many ground depth units each snowflake adds
+// when it lands (snow mode only). Values are clamped to [1, 8].
+func (rb *RainBackground) SetSnowAccumPerLanding(n int) {
+	if n < 1 {
+		n = 1
+	}
+	if n > 8 {
+		n = 8
+	}
+	rb.snowAccumPerLanding = n
+}
+
 func (rb *RainBackground) buildCloudRow() []string {
 	row := make([]string, rb.Width)
 	for x := 0; x < rb.Width; x++ {
@@ -412,9 +436,12 @@
 	if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
 		rb.GardenPlots = make([]gardenPlot, rb.Width)
 	}
+	if rb.Mode == config.UIRainAnimationSnow && rb.Width > 0 {
+		rb.initSnowScene()
+	}
 	startY := 0
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
-		startY = 1 // leave top row for clouds / sky
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
+		startY = 1 // leave top row for sky / clouds / night
 	}
 	targetCount := rb.Width * 2
 	if rb.Mode == config.UIRainAnimationGarden {
@@ -447,6 +474,10 @@
 			}
 		}
 	}
+	if rb.Mode == config.UIRainAnimationSnow {
+		char = snowflakeChars[rand.Intn(len(snowflakeChars))]
+		speed = 2 + rand.Intn(2)
+	}
 	drop := RainDrop{
 		X:        rand.Intn(rb.Width),
 		Y:        startY,
@@ -663,9 +694,9 @@
 
 	minY := 0
 	maxDropY := rb.Height - 1
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
 		minY = 1
-		maxDropY = rb.Height - 2 // leave bottom row for plants / flowers
+		maxDropY = rb.Height - 2 // leave bottom row for ground / plants / snow pile
 	}
 
 	if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny {
@@ -751,6 +782,16 @@
 			}
 			p.Age = p.MaxAge
 		}
+
+		if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) {
+			add := rb.snowAccumPerLanding
+			if add < 1 {
+				add = 1
+			}
+			rb.SnowGround[p.X] += add
+			rb.snowNoteFlakeLand(p.X)
+			p.Age = p.MaxAge
+		}
 	}
 
 	// Remove dead drops (off screen or expired)
@@ -775,7 +816,7 @@
 	}
 
 	// Periodically refresh cloud row in advanced / garden (storm) mode
-	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden) && rb.Frame%30 == 0 && rb.Width > 0 {
+	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow) && rb.Frame%30 == 0 && rb.Width > 0 {
 		rb.CloudRow = rb.buildCloudRow()
 	}
 
@@ -783,6 +824,10 @@
 		rb.gardenAdvancePlots()
 		rb.gardenMaybeFinishStorm()
 	}
+
+	if rb.Mode == config.UIRainAnimationSnow {
+		rb.snowAdvanceScene()
+	}
 }
 
 // Render returns the rain background as a string
@@ -816,6 +861,8 @@
 	switch rb.Mode {
 	case config.UIRainAnimationGarden:
 		rb.paintGardenOverlays(cells)
+	case config.UIRainAnimationSnow:
+		rb.paintSnowScene(cells)
 	case config.UIRainAnimationAdvanced:
 		// Top row: clouds
 		if len(rb.CloudRow) >= rb.Width {
@@ -1264,6 +1311,24 @@
 		return result.String()
 	}
 
+	if mode == config.UIRainAnimationSnow {
+		night := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F"))
+		snow := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1"))
+		for x := 0; x < width; x++ {
+			phase := float64(frame)*0.03 + float64(x)*0.11
+			ch := "·"
+			if int(phase*3)%4 == 0 {
+				ch = " "
+			} else if int(phase*5)%7 == 0 {
+				ch = snow.Render("*")
+				result.WriteString(ch)
+				continue
+			}
+			result.WriteString(night.Render(ch))
+		}
+		return result.String()
+	}
+
 	if mode == config.UIRainAnimationMatrix {
 		for x := 0; x < width; x++ {
 			phase := float64(frame)*0.075 + float64(x)*0.24
@@ -1321,6 +1386,9 @@
 	if mode == config.UIRainAnimationMatrix {
 		return matrixRainColors
 	}
+	if mode == config.UIRainAnimationSnow {
+		return snowRainColors
+	}
 	if mode == config.UIRainAnimationGarden {
 		return gardenRainColors
 	}
@@ -1367,3 +1435,15 @@
 	lipgloss.Color("#9FA8DA"),
 	lipgloss.Color("#C5CAE9"),
 }
+
+// snowRainColors is a cool snowflake palette (high → light).
+var snowRainColors = []lipgloss.Color{
+	lipgloss.Color("#263238"),
+	lipgloss.Color("#37474F"),
+	lipgloss.Color("#455A64"),
+	lipgloss.Color("#78909C"),
+	lipgloss.Color("#90A4AE"),
+	lipgloss.Color("#B0BEC5"),
+	lipgloss.Color("#CFD8DC"),
+	lipgloss.Color("#ECEFF1"),
+}

diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go
--- a/internal/ui/rain_bg_test.go
+++ b/internal/ui/rain_bg_test.go
@@ -285,3 +285,100 @@
 		t.Fatalf("expected drops cleared, got %d", len(rb.Drops))
 	}
 }
+
+func TestRenderRainWaveSnowWidth(t *testing.T) {
+	const width = 40
+	s := RenderRainWave(width, 3, config.UIRainAnimationSnow, false)
+	if got := lipgloss.Width(s); got != width {
+		t.Fatalf("lipgloss.Width(RenderRainWave snow) = %d, want %d", got, width)
+	}
+}
+
+func TestRainBackgroundSnowRenderLineWidths(t *testing.T) {
+	const w, h = 32, 8
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	for i := 0; i < 50; i++ {
+		rb.Update()
+	}
+	out := rb.Render()
+	lines := strings.Split(out, "\n")
+	if len(lines) != h {
+		t.Fatalf("expected %d lines, got %d", h, len(lines))
+	}
+	for i, line := range lines {
+		if got := lipgloss.Width(line); got != w {
+			t.Fatalf("line %d: lipgloss.Width = %d, want %d\n%q", i, got, w, line)
+		}
+	}
+}
+
+func TestSnowGroundDepthIncreases(t *testing.T) {
+	const w, h = 24, 6
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	rb.SetSnowAccumPerLanding(4)
+	sum0 := 0
+	for _, v := range rb.SnowGround {
+		sum0 += v
+	}
+	for i := 0; i < 120; i++ {
+		rb.Update()
+	}
+	sum1 := 0
+	for _, v := range rb.SnowGround {
+		sum1 += v
+	}
+	if sum1 <= sum0 {
+		t.Fatalf("expected snow ground accumulation, sum0=%d sum1=%d", sum0, sum1)
+	}
+}
+
+func TestSnowTreesPlacedWithSpacingAndScale(t *testing.T) {
+	const w, h = 48, 10
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	if len(rb.SnowTrees) < 2 {
+		t.Fatalf("expected at least 2 snow trees for %dx%d, got %d", w, h, len(rb.SnowTrees))
+	}
+	minGap := w / 12
+	if minGap < 4 {
+		minGap = 4
+	}
+	if minGap > 10 {
+		minGap = 10
+	}
+	for i := range rb.SnowTrees {
+		tr := rb.SnowTrees[i]
+		if tr.x < 1 || tr.x >= w-1 {
+			t.Fatalf("tree trunk x out of bounds: %d", tr.x)
+		}
+		if !rb.snowTreeSiteFree(tr.x) {
+			t.Fatalf("tree at x=%d overlaps cabin/snowman or clips canopy", tr.x)
+		}
+		for j := i + 1; j < len(rb.SnowTrees); j++ {
+			if d := absInt(tr.x - rb.SnowTrees[j].x); d < minGap {
+				t.Fatalf("trees closer than minGap %d: x=%d and x=%d", minGap, tr.x, rb.SnowTrees[j].x)
+			}
+		}
+	}
+}
+
+func TestSnowTreesAtLeastOneWhenMarginExists(t *testing.T) {
... diff truncated: showing 800 of 1635 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit f5360af. Configure here.

Comment thread internal/ui/repo_selector.go Outdated
Comment thread internal/ui/snow_scene.go
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