feat: add mascot animation#23
Conversation
- Cat sits idle at left, walks to desk when agents start running - Works at desk with paper-shuffling animation - Walks back left when agents stop - Desk, chair, and paper sprites with proper layout - Inline snapshot tests for all animation states Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove crouching_sprite and its idle trigger logic - Change walking sprites from thin ▐▌ to thick ▄ to match sitting sprite Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts: # README.md # src/state.rs # tests/bottom_tests.rs
# Conflicts: # README.md
Gate the cat mascot animation behind a new tmux option so it stays hidden by default. Users can opt in with `set -g @sidebar_mascot on`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts: # docs/state-management.md # src/cli/setup.rs # src/state.rs # src/ui/mod.rs # tests/bottom_tests.rs
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdded a mascot animation subsystem: new tmux option to enable it, AppState mascot fields and tick logic, mascot renderer, theme colors, UI wiring to render each spinner tick, docs/config updates, and tests for parsing, rendering, and mascot behavior. Changes
Sequence Diagram(s)sequenceDiagram
rect rgba(200,200,255,0.5)
participant Tmux as Tmux/Options
end
rect rgba(200,255,200,0.5)
participant App as App (main loop)
participant State as AppState
participant UI as UI / Mascot Renderer
participant Frame as TUI Frame
end
Tmux->>App: provide `@sidebar_mascot` & color options at init
App->>State: init_state(mascot_enabled)
loop spinner tick (~200ms)
App->>State: tick_spinner()
alt mascot_enabled
App->>State: tick_mascot(term_width)
State->>UI: expose mascot state & fields
UI->>Frame: draw_mascot(frame, state, bottom_area, running_count)
Frame->>UI: rendered output
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/state.rs`:
- Around line 1651-1673: The mascot walking logic only toggles self.mascot_frame
between 1 and 2, so frame 3 (walking_right_3/walking_left_3) is never reached;
update the frame advancement in both MascotState::WalkRight and
MascotState::WalkLeft branches (the place that currently does self.mascot_frame
= if self.mascot_frame == 1 { 2 } else { 1 }) to cycle through 1→2→3→1 (e.g.
increment with wraparound using modulus or a small match on self.mascot_frame),
leaving the rest of the movement logic (mascot_x, mascot_walk_tick,
reseed_mascot_walk_bounce, mascot_walk_bounce_lift_until, etc.) unchanged so all
three frames can appear.
In `@src/ui/mascot.rs`:
- Around line 508-519: The helper walking_vertical_lift currently ignores the
scheduled window and only uses the phase calc; update it to also consult
AppState's scheduled fields (mascot_walk_bounce_next_tick and
mascot_walk_bounce_lift_until) so the lift returns 2 only when both the existing
walking/phase conditions and the current tick falls inside the scheduled bounce
window (i.e., between mascot_walk_bounce_next_tick and
mascot_walk_bounce_lift_until); modify the conditional in walking_vertical_lift
to include that time-window check and leave other logic (phase, is_walking)
intact.
- Around line 621-627: The right-edge clipping undercounts by one because
available is computed as frame.area().width.saturating_sub(x.saturating_add(1));
change it to subtract x only (frame.area().width.saturating_sub(x)) so a sprite
starting exactly n cells from the right has n available columns; keep the
existing check for available == 0 and min clipping (w =
line_width.min(available)) and keep creating the Rect with that width
(Rect::new(x, y, w, 1)) — reference the variables available, x, line_width, w
and the Rect::new call in mascot.rs.
🪄 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: 7d0a6292-cba8-4a51-8125-ae28c639c9ab
📒 Files selected for processing (10)
README.mdagent-sidebar.confdocs/state-management.mdsrc/cli/hook.rssrc/main.rssrc/state.rssrc/ui/colors.rssrc/ui/mascot.rssrc/ui/mod.rstests/color_tests.rs
# Conflicts: # README.md # src/cli/hook.rs # src/main.rs # src/state.rs
- Cycle mascot_frame 1→2→3→1 in WalkRight/WalkLeft so the third sprite frame actually renders. - Replace the phase-based walking bounce lift with the window already scheduled by tick_mascot (mascot_walk_bounce_lift_until), so sprite lift and state-machine timing stay in sync. - Fix off-by-one in render_lines right-edge clipping and account for non-zero frame.area().x. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/state.rs (1)
778-785: Reuserunning_count()insidetick_mascot.
draw_mascotalready consumesstate.running_count()viasrc/ui/mod.rs, buttick_mascotkeeps a second copy of the same traversal. That makes the motion trigger and the rendered paper height easy to drift apart on the next change.♻️ Proposed cleanup
- let running_count = self - .repo_groups - .iter() - .flat_map(|g| &g.panes) - .filter(|(p, _)| p.status == crate::tmux::PaneStatus::Running) - .count(); + let running_count = self.running_count();Also applies to: 1129-1134
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/state.rs` around lines 778 - 785, The traversal that counts running agents is duplicated inside tick_mascot; replace that duplicated flat_map/filter/count logic by calling the existing running_count(&self) method so both draw_mascot and tick_mascot use the same source of truth. Locate tick_mascot and remove its manual iteration over repo_groups/panes, calling self.running_count() where the count was used (also apply the same replacement for the other duplicate traversal in tick_mascot). Ensure borrow/immutability matches the surrounding code when swapping in the method call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/ui/mascot.rs`:
- Around line 900-903: Tests compute stop_x using CHAIR_WIDTH + 2 but the
runtime parks the mascot at CHAIR_WIDTH + 3 in AppState::tick_mascot(); update
the test expectations to use CHAIR_WIDTH + 3 (i.e., change the
working_width/stop_x calculation to match CHAIR_WIDTH + 3) so mascot_x aligns
with AppState::tick_mascot()’s parked Working position—apply the same +3 fix to
the other occurrences referenced (the blocks around the other two snapshots).
---
Nitpick comments:
In `@src/state.rs`:
- Around line 778-785: The traversal that counts running agents is duplicated
inside tick_mascot; replace that duplicated flat_map/filter/count logic by
calling the existing running_count(&self) method so both draw_mascot and
tick_mascot use the same source of truth. Locate tick_mascot and remove its
manual iteration over repo_groups/panes, calling self.running_count() where the
count was used (also apply the same replacement for the other duplicate
traversal in tick_mascot). Ensure borrow/immutability matches the surrounding
code when swapping in the method call.
🪄 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: 5673b6e2-d841-40a0-93ac-21f58de53cf5
📒 Files selected for processing (2)
src/state.rssrc/ui/mascot.rs
The runtime parks the mascot at CHAIR_WIDTH + 3 (src/state.rs:1137), but the three Working-state snapshot tests were locking CHAIR_WIDTH + 2. Update working_width and the expected frames so a real off-by-one in the desk layout would fail these snapshots. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mascot sprites were rendering from baseline = chunks[2].y - 1 into a 1-row divider, spilling the head/body into the pane list above. Grow the divider (chunks[1]) to MASCOT_SCENE_HEIGHT (5) when the mascot is enabled and pass that band as the mascot's drawable area. The mascot still visually sits on top of the bottom panel — position unchanged — but it no longer overdraws pane rows and the bottom panel border stays intact. Adds a snapshot integration test that locks in this layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/ui/mascot.rs (2)
888-898: Consider usinginsta::assert_snapshot!for visual regression tests.The snapshot tests use
assert_eq!with expected strings, which works but requires manual updates when sprites change. Theinstacrate provides better ergonomics for maintaining visual snapshots.♻️ Example refactor using insta
#[test] fn snapshot_idle() { let state = AppState::new("%0".into()); let output = render_mascot_scene(&state, 0, 40, 14); - let expected = [ - " ▄ ▄", - " ▄▀▀▀▄ ████", - " ▀ ▀ ██ █ █", - ] - .join("\n"); - assert_eq!(output, expected); + insta::assert_snapshot!(output, `@r`###" + ▄ ▄ + ▄▀▀▀▄ ████ + ▀ ▀ ██ █ █ + "###); }Based on learnings: "In UI tests that render a frame, MUST use
insta::assert_snapshot!(output, @"")inline snapshots instead ofassert!(output.contains(...))or substring checks for stronger visual regression detection"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/ui/mascot.rs` around lines 888 - 898, The test snapshot_idle currently compares the rendered mascot string using assert_eq!, which is brittle; change it to use insta's inline snapshot macro to enable visual regression management: add the insta crate as a dev-dependency if not present, then in the snapshot_idle test (which calls AppState::new and render_mascot_scene) replace the assert_eq!(output, expected) with insta::assert_snapshot!(output, @""<put the current expected multi-line snapshot here> ""); ensure the snapshot content matches the existing expected value so the first run records it and future changes are managed by insta.
625-627: Height clipping check may be incorrect whenframe.area().y > 0.The check
y >= frame.area().heightcompares against the height dimension rather than the actual bottom boundary. Ifframe.area()ever has a non-zeroyorigin, valid rows would be incorrectly skipped.In typical terminal rendering where
frame.area().y == 0, this works fine. If the frame area can have a non-zero origin in any use case, consider:🔧 Suggested fix for edge case
- if y >= frame.area().height { + let area = frame.area(); + if y < area.y || y >= area.y + area.height { continue; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/ui/mascot.rs` around lines 625 - 627, The height clipping check inside the loop incorrectly compares the absolute row index y against frame.area().height; update the check in the function containing that loop (look for the loop using variable `y` and `frame.area()`) to compare against the frame's bottom boundary instead (e.g., use frame.area().y + frame.area().height or a provided bottom() method) so rows are clipped relative to the frame's origin rather than to the height alone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/ui/mascot.rs`:
- Line 20: Remove the unused public constant MASCOT_WIDTH from src/ui/mascot.rs
or restore its usage; specifically either delete the declaration pub const
MASCOT_WIDTH: u16 = 7; to eliminate the unused symbol, or update the
mascot-related code (e.g., the rendering/dimensions functions in mascot.rs and
any consumers like state.rs) to reference MASCOT_WIDTH where the mascot's width
is required so the constant is actually used.
---
Nitpick comments:
In `@src/ui/mascot.rs`:
- Around line 888-898: The test snapshot_idle currently compares the rendered
mascot string using assert_eq!, which is brittle; change it to use insta's
inline snapshot macro to enable visual regression management: add the insta
crate as a dev-dependency if not present, then in the snapshot_idle test (which
calls AppState::new and render_mascot_scene) replace the assert_eq!(output,
expected) with insta::assert_snapshot!(output, @""<put the current expected
multi-line snapshot here> ""); ensure the snapshot content matches the existing
expected value so the first run records it and future changes are managed by
insta.
- Around line 625-627: The height clipping check inside the loop incorrectly
compares the absolute row index y against frame.area().height; update the check
in the function containing that loop (look for the loop using variable `y` and
`frame.area()`) to compare against the frame's bottom boundary instead (e.g.,
use frame.area().y + frame.area().height or a provided bottom() method) so rows
are clipped relative to the frame's origin rather than to the height alone.
🪄 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: dde07b4c-cf76-4923-9b35-83af676e6bb7
📒 Files selected for processing (3)
src/ui/mascot.rssrc/ui/mod.rstests/bottom_tests.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- tests/bottom_tests.rs
- src/ui/mod.rs
It's declared but referenced nowhere in the codebase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # README.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Align docs/ with the current Rust API after a docs-audit pass: - NoticesState: claude_plugin_installed_version → claude_plugin_status - PermissionMode: add missing Defer variant - PopupState: document SpawnInput and RemoveConfirm variants - TODO-background-shell-detection: rename from_str → from_label Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address blocking issues found by spec-document-reviewer: - BOTTOM_HEIGHT must remain non-zero so the mascot band renders - @sidebar_mascot must be enabled per scenario - Walking timing math corrected (22 ticks / 4.4s, not 24 / 4.8s) - Idle scenario uses post-build_layout @pane_status overrides - Add reference/tmux-options entry for @sidebar_mascot Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds idle/walking/working PNGs alongside the existing capture set, wired into scripts/build-assets.sh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # docs/state-management.md # src/ui/colors.rs
Main's post-split state.rs structure is adopted wholesale; the mascot
additions that previously lived in monolithic state.rs are re-homed:
- 20 mascot_* fields added to the AppState struct in state.rs
- `new()` initialisers for the new fields, plus the opening
reseed_mascot_idle_motion() call
- src/state/mascot.rs (new) holds:
- 3 reseed helpers (reseed_mascot_idle_motion,
reseed_mascot_working_paper_motion, reseed_mascot_walk_bounce)
- impl AppState { running_count(), tick_mascot() }
- the 9 mascot state-machine tests
mod mascot; registered in state.rs next to the sibling submodules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve conflicts: - docs/TODO-background-shell-detection.md: removed (resolved by #50) - src/port.rs: take main (process matching extracted to src/process.rs; command_basename already covers path-comm fix from 9cdf046) - src/tmux/query.rs: take main's process_snapshot_for_panes design; drop local helpers; keep apply_codex_permission_modes_matches_path_comm test on the new ProcessSnapshot API - src/ui/colors.rs: adopt main's tmux::SIDEBAR_COLOR_* constants while preserving mascot_body / mascot_eye reads; add the two missing constants in src/tmux/options.rs and re-export via src/tmux.rs - src/state/mascot.rs: add bg_shell_cmd to PaneInfo test fixture - tests/bottom_tests.rs: refresh mascot snapshot for new background icon
Summary by CodeRabbit
New Features
UI / Behavior
Documentation
Tests