FEAT Animated ASCII banner with raccoon mascot for PyRIT CLI#1417
FEAT Animated ASCII banner with raccoon mascot for PyRIT CLI#1417romanlutz wants to merge 19 commits intoAzure:mainfrom
Conversation
- Create pyrit/cli/banner.py with frame-based animation engine - Raccoon mascot walks in from right, PYRIT text reveals left-to-right - Semantic color roles with light/dark terminal theme support - Graceful degradation: static banner when not a TTY, NO_COLOR, CI, or --no-animation - Ctrl+C during animation skips to static banner - Add --no-animation flag to pyrit_shell CLI - 24 unit tests covering color roles, themes, animation capability detection, frames, and fallback - Update existing shell tests for new banner integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move cursor up (frame_height - 1) lines instead of frame_height, since the rendered frame has (N-1) newlines for N lines, leaving the cursor on the last line rather than below it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Redesign raccoon with bandit mask (=o.o=), w nose, wider head (/\___/\), and striped tail (~~) to look like a raccoon not a cat - Reserve vertical space before animation to prevent scroll drift when cursor is near bottom of terminal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Single raccoon with clear features: pointed ears, bandit mask (=o o=), w nose, bushy striped tail (~~~~~) - Remove duplicate right-side raccoon from commands section - Fix animation drift: wait for background init to complete before playing animation so log messages don't corrupt cursor positioning - Build banner programmatically to guarantee correct line widths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The raccoon is now rendered using Unicode braille characters with inverted dots — the raccoon face is drawn as positive space on an empty background, blending naturally with the banner's box-drawing and block-letter aesthetic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
User-edited raccoon trimmed to 12 lines. Cleared bottom-row braille dots (bits 6-7) from chin characters to avoid visual artifacts below the chin line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use RACCOON_BODY color for all header lines during animation Phase 2 instead of PYRIT_TEXT for lines with block letters. This ensures the entire raccoon lights up uniformly. Also fix PYRIT_START_ROW (2) so both subtitles fit within HEADER_ROWS (12), add lowercase y to block letters, and remove stray braille dots from chin line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Normalize all braille raccoon lines to exactly 30 chars (replace mixed regular spaces with braille empty U+2800) to fix alignment - Use RACCOON_BODY color for all header rows including subtitle rows so the entire raccoon lights up consistently - Add striped raccoon tail hanging from divider into commands section with alternating thick/thin stripes and bushy tip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove SUBTITLE color overrides in Phase 2 and Phase 3 animation so all raccoon lines use consistent RACCOON_BODY color throughout - Shift chin motif left by 2 positions to center under face - Redesign tail as 6-char wide braille art with alternating dense/ sparse stripes tapering from wide to narrow tip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tail now starts at w=5, bulges to w=6, then tapers through 5,4,3,2,1. Dark stripes use full braille fill, light stripes use thin edge delimiters only (top dots at boundaries). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tail is now 9 chars wide (50% wider), curls rightward with increasing offset per line, and uses vertical braille delimiters (left=dots 1,2,3 right=dots 4,5,6) on light stripes instead of horizontal ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lines with tail content now always use COMMANDS color instead of inheriting BORDER color from empty separator lines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tail offsets now follow 0,0,1,2,3,3,3,2,1,0 creating a proper curl that curves right then sweeps back left at the tip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Major animation upgrade inspired by GitHub Copilot CLI approach: - Per-segment coloring: AnimationFrame now supports segment_colors for different colors within the same line (raccoon=magenta, PYRIT text=cyan, subtitles=white, tail=magenta, stars=yellow) - Vibrant ANSI color theme: bright_cyan for PYRIT text, bright_magenta for raccoon/tail, bright_yellow for sparkles - Sparkle stars (✦ ✧ · *) appear during raccoon entry and celebration phases at randomized positions - 3-frame sparkle celebration instead of 2 - Phase 4 preserves segment colors from static banner Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sparkle segments in Phase 3 overlapped with PYRIT_TEXT and SUBTITLE segments, causing _render_line_with_segments to output characters twice (e.g. 106 visible chars instead of 96). Rewrote the function to use a per-character color map where later segments override earlier ones, then group consecutive same-role characters for rendering. This guarantees visible output width matches input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add per-segment coloring to quick start lines and non-tail command lines so the border characters (║) use BORDER color (cyan) while the text content uses COMMANDS color (white). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds an animated (with static fallback) startup banner to pyrit_shell, including a new banner module and accompanying unit tests, plus a --no-animation flag to disable animation.
Changes:
- Introduces
pyrit/cli/banner.pyimplementing frame-based terminal animation with theme-aware ANSI coloring and static fallback. - Updates
pyrit_shellto play the banner before starting the REPL and adds--no-animationCLI flag. - Adds/updates unit tests for the banner behavior and adjusts shell tests for the new intro behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
pyrit/cli/banner.py |
New banner implementation (static + animation rendering, theme detection, animation gating). |
pyrit/cli/pyrit_shell.py |
Integrates banner via cmdloop() override; adds --no-animation flag wiring. |
tests/unit/cli/test_banner.py |
New unit tests for banner colors/themes/frames/static/animation gating. |
tests/unit/cli/test_pyrit_shell.py |
Updates intro expectation now that intro is set via cmdloop()/banner. |
|
|
||
| def _box_line(content: str) -> str: | ||
| """Wrap content in box border chars, padded to BOX_W.""" | ||
| return "║" + content.ljust(BOX_W) + "║" |
There was a problem hiding this comment.
_box_line() claims to pad content to BOX_W, but it doesn’t handle content longer than BOX_W—ljust() won’t truncate, so the returned string can exceed the intended box width and break border alignment. Truncate to BOX_W before padding (e.g., slice then ljust).
| return "║" + content.ljust(BOX_W) + "║" | |
| truncated_content = content[:BOX_W] | |
| return "║" + truncated_content.ljust(BOX_W) + "║" |
| # Per-segment colors | ||
| segs: list[tuple[int, int, ColorRole]] = [ | ||
| (0, 1, ColorRole.BORDER), | ||
| (1, 1 + RACCOON_COL, ColorRole.RACCOON_BODY), | ||
| ] | ||
| pyrit_start = 1 + RACCOON_COL | ||
| if 0 <= pyrit_idx < len(PYRIT_LETTERS): | ||
| segs.append((pyrit_start, pyrit_start + PYRIT_WIDTH, ColorRole.PYRIT_TEXT)) | ||
| elif row_i in (subtitle_row_1, subtitle_row_2): | ||
| segs.append((pyrit_start, len(full_line) - 1, ColorRole.SUBTITLE)) | ||
| # Add sparkle color segments | ||
| for s_row, s_col, _ in spots: | ||
| if row_i == s_row and 1 < s_col < BOX_W: | ||
| segs.append((s_col, s_col + 1, ColorRole.SPARKLE)) | ||
| segs.append((len(full_line) - 1, len(full_line), ColorRole.BORDER)) |
There was a problem hiding this comment.
In the Phase 3 “sparkle celebration” frames, the per-segment color list doesn’t assign a role for the area between the end of the PYRIT text and the right border (unlike Phase 2/static rendering, which fills the remainder with ColorRole.BORDER). Unassigned positions render with the terminal’s default color, which can cause visible flicker/inconsistent colors. Add a ColorRole.BORDER segment covering the remainder of the line (up to the right border) when pyrit_idx is in range.
| Args: | ||
| no_animation: If True, skip animation and return static banner. | ||
|
|
||
| Returns: | ||
| The final static banner string (to be used as the shell intro). |
There was a problem hiding this comment.
play_animation()’s docstring says it returns the final static banner string, but when animation runs it prints to stdout and returns an empty string. This mismatch makes the function’s contract unclear for callers. Update the docstring (and/or return value) to accurately describe that it returns a banner string only when animation is skipped, and returns an empty string when it handled printing itself.
| Args: | |
| no_animation: If True, skip animation and return static banner. | |
| Returns: | |
| The final static banner string (to be used as the shell intro). | |
| When animation is enabled and supported, this function renders the | |
| animation and the final static banner directly to stdout and returns | |
| an empty string. When animation is disabled or not supported, it does | |
| not write to stdout and instead returns the static banner string for | |
| the caller to display. | |
| Args: | |
| no_animation (bool): If True, skip animation and return the static | |
| banner without performing any animated output. | |
| Returns: | |
| str: The static banner string when animation is skipped or | |
| unsupported; otherwise an empty string after the banner has | |
| been printed to stdout. |
tests/unit/cli/test_banner.py
Outdated
| import pytest | ||
|
|
There was a problem hiding this comment.
pytest is imported but never used in this test module. Ruff has F401 (unused import) enabled, so this will fail linting. Remove the import pytest line (or use it if intended).
| import pytest |
| # intro is now set dynamically in cmdloop via banner.play_animation | ||
| # Verify that calling play_animation with no_animation produces expected content | ||
| from pyrit.cli.banner import get_static_banner | ||
|
|
||
| static = get_static_banner() |
There was a problem hiding this comment.
There is an inline import inside test_prompt_and_intro. This makes imports harder to track and is inconsistent with the rest of the test module’s import style. Move the from pyrit.cli.banner import get_static_banner import to the top of the file.
| self.intro = banner.play_animation(no_animation=self._no_animation) | ||
| super().cmdloop(intro=self.intro) | ||
|
|
There was a problem hiding this comment.
cmdloop() ignores its intro argument and always overwrites self.intro with banner.play_animation(...). This breaks the expected cmd.Cmd.cmdloop(intro=...) contract and makes it hard to supply a custom intro (e.g., in tests or embedding). Consider honoring a non-None intro by passing it through unchanged, and only calling play_animation when intro is None.
| self.intro = banner.play_animation(no_animation=self._no_animation) | |
| super().cmdloop(intro=self.intro) | |
| if intro is None: | |
| self.intro = banner.play_animation(no_animation=self._no_animation) | |
| super().cmdloop(intro=self.intro) | |
| return | |
| self.intro = intro | |
| super().cmdloop(intro=intro) |
| parser.add_argument( | ||
| "--no-animation", | ||
| action="store_true", | ||
| default=False, | ||
| help="Disable the animated startup banner (show static banner instead)", | ||
| ) |
There was a problem hiding this comment.
The new --no-animation CLI flag is wired into main(), but there’s no unit test asserting that the parsed flag is passed through to PyRITShell(..., no_animation=...). Since tests/unit/cli/test_pyrit_shell.py already has TestMain argument-parsing coverage, add a test case for --no-animation to prevent regressions.
- Add Returns sections to all function docstrings (DOC201) - Simplify can_animate() return logic (SIM103) - Remove unused loop variable sparkle_idx (B007) - Fix segs variable redefinition in _build_animation_frames (mypy) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply ruff formatting fixes and auto-fixable lint issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| for line in frame.lines: | ||
| # All lines should start with ╔/║/╠/╚ and end with ╗/║/╣/╝ | ||
| assert line[0] in "╔║╠╚", f"Line doesn't start with box char: {line[:5]}..." |
There was a problem hiding this comment.
Test name vs assertion mismatch: test_all_frames_have_consistent_width doesn’t actually verify consistent width or that lines end with the expected box characters (it only checks the first character). Either strengthen the assertions (e.g., check equal len(line) and correct ending char) or rename the test to match what it verifies.
| for line in frame.lines: | |
| # All lines should start with ╔/║/╠/╚ and end with ╗/║/╣/╝ | |
| assert line[0] in "╔║╠╚", f"Line doesn't start with box char: {line[:5]}..." | |
| assert frame.lines, "Animation frame has no lines" | |
| expected_width = len(frame.lines[0]) | |
| for line in frame.lines: | |
| # All lines should start with ╔/║/╠/╚ and end with ╗/║/╣/╝, and have consistent width | |
| assert ( | |
| len(line) == expected_width | |
| ), f"Inconsistent line width in frame: expected {expected_width}, got {len(line)}" | |
| assert line[0] in "╔║╠╚", f"Line doesn't start with box char: {line[:5]}..." | |
| assert line[-1] in "╗║╣╝", f"Line doesn't end with box char: {line[-5:]}..." |
| context: frontend_core.FrontendCore, | ||
| no_animation: bool = False, | ||
| ): |
There was a problem hiding this comment.
PyRITShell.__init__ is missing a -> None return annotation, and with more than one parameter beyond self it should enforce keyword-only args (e.g., def __init__(self, *, context: ..., no_animation: bool = False) -> None) to match the project’s function signature conventions (see FrontendCore.__init__).
| context: frontend_core.FrontendCore, | |
| no_animation: bool = False, | |
| ): | |
| *, | |
| context: frontend_core.FrontendCore, | |
| no_animation: bool = False, | |
| ) -> None: |
| return False | ||
| if os.environ.get("PYRIT_NO_ANIMATION"): | ||
| return False | ||
| # CI environments | ||
| return not os.environ.get("CI") |
There was a problem hiding this comment.
can_animate() treats any presence of the CI env var as “in CI” (return not os.environ.get("CI")). This will incorrectly disable animation when CI is set to values like "0"/"false". Consider interpreting CI as a boolean (e.g., only disable when it’s one of 1,true,yes,on) or checking for common CI-specific vars instead.
| def play_animation(no_animation: bool = False) -> str: | ||
| """ | ||
| Play the animated banner or return the static banner. | ||
|
|
||
| Args: | ||
| no_animation: If True, skip animation and return static banner. | ||
|
|
||
| Returns: | ||
| The final static banner string (to be used as the shell intro). | ||
| """ | ||
| if no_animation or not can_animate(): | ||
| return get_static_banner() | ||
|
|
There was a problem hiding this comment.
play_animation’s docstring says it returns the final static banner string, but in the animation path it prints the banner and returns an empty string. Please align the docstring and return behavior (either return the final banner consistently, or document that an empty string is returned when animation is played because output is written directly to stdout).
| def test_prompt_and_intro(self): | ||
| """Test shell prompt and intro are set.""" | ||
| """Test shell prompt is set and intro is set via cmdloop.""" | ||
| mock_context = MagicMock() | ||
| mock_context.initialize_async = AsyncMock() | ||
|
|
||
| shell = pyrit_shell.PyRITShell(context=mock_context) | ||
|
|
||
| assert shell.prompt == "pyrit> " | ||
| assert shell.intro is not None | ||
| assert "Interactive Shell" in str(shell.intro) | ||
| # intro is now set dynamically in cmdloop via banner.play_animation | ||
| # Verify that calling play_animation with no_animation produces expected content | ||
| from pyrit.cli.banner import get_static_banner | ||
|
|
||
| static = get_static_banner() | ||
| assert "Interactive Shell" in static |
There was a problem hiding this comment.
The updated test no longer verifies the new behavior that PyRITShell.cmdloop() sets intro via banner.play_animation (it only calls get_static_banner() directly). Consider adding a focused test that patches banner.play_animation and the base cmd.Cmd.cmdloop to assert the intro wiring, and also add coverage that --no-animation is threaded through to PyRITShell/banner.play_animation.
Summary
Adds an animated startup banner to pyrit_shell featuring a braille-art raccoon mascot, the PyRIT block-letter logo, and a striped curling tail —
inspired by the GitHub Copilot CLI's animated banner approach.
What it looks like
The banner plays a ~2.5 second animation on shell startup:
Architecture
- Per-segment coloring: Different ANSI colors within the same line (raccoon=magenta, PyRIT=cyan, tail=magenta, stars=yellow, border=cyan)
- Semantic color roles mapped to 4-bit ANSI palette with dark/light theme support
- Braille raccoon art — high-detail inverted Unicode braille face with bandit mask
- Curling striped tail — 9-char wide braille tail with S-curve shape and alternating dense/sparse stripes
- Graceful degradation: falls back to static banner for non-TTY, NO_COLOR, PYRIT_NO_ANIMATION, CI environments
- Ctrl+C skips animation to static banner immediately
corrupting cursor positioning. Adds --no-animation CLI flag.
How to test
Animated banner
python -m pyrit.cli.pyrit_shell
Static banner (skip animation)
python -m pyrit.cli.pyrit_shell --no-animation
Environment variable to disable
PYRIT_NO_ANIMATION=1 python -m pyrit.cli.pyrit_shell
Scope
roakey.banner.mp4