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 26 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. |
pyrit/cli/banner.py
Outdated
|
|
||
| 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.
pyrit/cli/banner.py
Outdated
| 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.
pyrit/cli/pyrit_shell.py
Outdated
| 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: |
pyrit/cli/banner.py
Outdated
| 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.
pyrit/cli/banner.py
Outdated
| # Add trailing stars in later frames | ||
| if i >= 2: | ||
| for s_row, s_col in star_positions[: i - 1]: | ||
| if r_idx == s_row and s_col < BOX_W and content[s_col] == " ": | ||
| star = star_chars[(s_row + i) % len(star_chars)] | ||
| content = content[:s_col] + star + content[s_col + 1 :] | ||
| line_idx = len(lines) | ||
| seg_colors.setdefault(line_idx, []).append( | ||
| (s_col + 1, s_col + 2, ColorRole.SPARKLE) # +1 for ║ | ||
| ) | ||
| color_map[len(lines)] = ColorRole.RACCOON_BODY | ||
| lines.append("║" + content + "║") |
There was a problem hiding this comment.
In Phase 1 frames, seg_colors is only populated with SPARKLE segments (no base segments for border/raccoon). Because _render_frame() uses segment_colors as a full override for that line, any line that gets a star will lose the intended raccoon/border coloring and render mostly with the reset color. Consider either (a) including full base segments for those lines (border + raccoon body + border) and then adding the sparkle segment, or (b) changing _render_line_with_segments/_render_frame so uncovered characters fall back to the line’s color_map role instead of reset.
pyrit/cli/banner.py
Outdated
| # Add sparkle characters | ||
| for s_row, s_col, s_char in spots: | ||
| if row_i == s_row and 1 < s_col < BOX_W and full_line[s_col] == " ": | ||
| full_line = full_line[:s_col] + s_char + full_line[s_col + 1 :] | ||
|
|
||
| # Per-segment colors | ||
| segs = [ | ||
| (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)) |
There was a problem hiding this comment.
The sparkle placement in Phase 3 appears to treat s_col as an index into the full boxed line (full_line), but _box_line() prefixes a left border at index 0. Given the 1 < s_col < BOX_W bounds (which match inner-content coordinates), this is off by one: the character insert and the SPARKLE segment should likely use s_col + 1 to target the intended inner column.
pyrit/cli/banner.py
Outdated
| 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 to be used as the shell intro, but when animation runs it prints to stdout and returns an empty string. Please update the docstring (or the function behavior) so callers have an accurate contract (e.g., explicitly document the empty-string return when the banner is already printed).
| no_animation: If True, skip animation and return static banner. | |
| Returns: | |
| The final static banner string (to be used as the shell intro). | |
| no_animation (bool): If True, skip animation and return the static banner | |
| string instead of printing an animation. | |
| Returns: | |
| str: If animation is disabled (either because ``no_animation`` is True or | |
| animation is not supported in the current environment), returns the static | |
| banner string to be used as the shell intro. If animation runs, the banner | |
| (including the final static frame) is written directly to stdout and an | |
| empty string is returned. |
pyrit/cli/banner.py
Outdated
| star = star_chars[(s_row + i) % len(star_chars)] | ||
| content = content[:s_col] + star + content[s_col + 1 :] | ||
| line_idx = len(lines) | ||
| seg_colors.setdefault(line_idx, []).append( |
There was a problem hiding this comment.
In phase 1, when a star is added you create a segment_colors entry that only marks the sparkle segment. Because _render_frame() treats segment_colors as an override, those lines will render everything except the sparkle in the reset color (dropping the intended raccoon/body color for that frame line). Consider either (a) not using segment_colors here and relying on color_map, or (b) adding base segments (border + raccoon body) along with the sparkle segment so the whole line is colored correctly.
| seg_colors.setdefault(line_idx, []).append( | |
| segments = seg_colors.setdefault(line_idx, []) | |
| if not segments: | |
| # Base coloring for this bordered line: left border, interior body, right border | |
| segments.append((0, 1, ColorRole.BORDER)) | |
| segments.append((1, BOX_W + 1, ColorRole.RACCOON_BODY)) | |
| segments.append((BOX_W + 1, BOX_W + 2, ColorRole.BORDER)) | |
| segments.append( |
| # 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.
This test no longer exercises the updated shell behavior (intro being set via the cmdloop() override and respecting the no_animation flag). As written, it only asserts that the banner module’s static text contains a substring, which doesn’t validate PyRITShell integration. Consider patching pyrit.cli.banner.play_animation and cmd.Cmd.cmdloop to a no-op, then calling shell.cmdloop() and asserting play_animation was called with no_animation and that cmdloop was invoked with the returned intro.
| # 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 | |
| # Verify that cmdloop uses the intro returned by play_animation and respects no_animation | |
| with patch("pyrit.cli.banner.play_animation") as mock_play_animation, patch( | |
| "cmd.Cmd.cmdloop" | |
| ) as mock_cmdloop: | |
| mock_play_animation.return_value = "TEST_INTRO" | |
| shell.cmdloop() | |
| mock_play_animation.assert_called_once_with(no_animation=shell.no_animation) | |
| mock_cmdloop.assert_called_once_with(intro="TEST_INTRO") |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| for s_row, s_col, _ in spots: | ||
| target_col = s_col + 1 | ||
| if row_i == s_row and 1 < s_col < BOX_W and target_col < len(full_line) - 1: |
There was a problem hiding this comment.
In the sparkle celebration phase, sparkle color segments are added based on coordinates only, without checking whether a sparkle character was actually inserted at that position (insertion is gated on full_line[target_col] == " "). If the target position is non-space (e.g., overlaps PYRIT letters), no sparkle is inserted but the segment still recolors the existing character as SPARKLE. Consider aligning the segment-color condition with the insertion condition (or checking the character at target_col) so only actual sparkle chars get the sparkle color.
| for s_row, s_col, _ in spots: | |
| target_col = s_col + 1 | |
| if row_i == s_row and 1 < s_col < BOX_W and target_col < len(full_line) - 1: | |
| for s_row, s_col, s_char in spots: | |
| target_col = s_col + 1 | |
| if ( | |
| row_i == s_row | |
| and 1 < s_col < BOX_W | |
| and target_col < len(full_line) - 1 | |
| and full_line[target_col] == s_char | |
| ): |
| except KeyboardInterrupt: | ||
| # User pressed Ctrl+C — show static banner immediately | ||
| sys.stdout.write("\r\033[J") # clear from cursor to end of screen | ||
| static = _render_static_banner(theme) | ||
| sys.stdout.write(static) | ||
| sys.stdout.write("\n") | ||
| sys.stdout.flush() |
There was a problem hiding this comment.
In the KeyboardInterrupt handler, the code clears from the current cursor position to the end of the screen, but it does not first move the cursor back to the top of the reserved animation area. If Ctrl+C happens after a frame is rendered, earlier frame lines can remain on screen above the static banner. Consider moving the cursor up by the reserved frame height (similar to the normal final-frame path) and clearing the reserved region before writing the static banner.
| class ColorRole(Enum): | ||
| """Semantic color roles for banner elements.""" | ||
|
|
||
| BORDER = "border" | ||
| PYRIT_TEXT = "pyrit_text" | ||
| SUBTITLE = "subtitle" | ||
| RACCOON_BODY = "raccoon_body" | ||
| RACCOON_MASK = "raccoon_mask" | ||
| RACCOON_EYES = "raccoon_eyes" | ||
| RACCOON_TAIL = "raccoon_tail" | ||
| SPARKLE = "sparkle" | ||
| COMMANDS = "commands" | ||
| RESET = "reset" |
There was a problem hiding this comment.
ColorRole defines RACCOON_MASK and RACCOON_EYES (and both themes map them), but these roles are never referenced when building segment colors or color maps. This makes the public enum/theme mappings harder to reason about; either remove the unused roles or apply them when constructing the raccoon art segments.
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