Skip to content

FEAT Animated ASCII banner with raccoon mascot for PyRIT CLI#1417

Open
romanlutz wants to merge 19 commits intoAzure:mainfrom
romanlutz:romanlutz/animated-ascii-banner
Open

FEAT Animated ASCII banner with raccoon mascot for PyRIT CLI#1417
romanlutz wants to merge 19 commits intoAzure:mainfrom
romanlutz:romanlutz/animated-ascii-banner

Conversation

@romanlutz
Copy link
Contributor

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:

  1. Raccoon slides in from the right with sparkle stars (✦ ✧)
  2. PyRIT text reveals left-to-right in cyan block letters
  3. Sparkle celebration with stars at varied positions
  4. Commands section appears with a striped raccoon tail curling down from the divider

Architecture

  • pyrit/cli/banner.py (659 lines) — Frame-based animation engine with:
    - 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
  • pyrit/cli/pyrit_shell.py — Integrates via cmdloop() override; waits for background init to complete before animation to prevent log messages
    corrupting cursor positioning. Adds --no-animation CLI flag.
  • tests/unit/cli/test_banner.py (24 tests) — Colors, themes, animation capability, frame generation, static banner content, segment rendering

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

  • Only affects pyrit_shell — pyrit_scan is unchanged
  • No new dependencies
  • All 64 CLI tests pass (24 banner + 40 shell)
roakey.banner.mp4

romanlutz and others added 16 commits February 26, 2026 13:49
- 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>
Copilot AI review requested due to automatic review settings February 27, 2026 23:46
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.py implementing frame-based terminal animation with theme-aware ANSI coloring and static fallback.
  • Updates pyrit_shell to play the banner before starting the REPL and adds --no-animation CLI 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) + "║"
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

_box_line() claims to pad content to BOX_W, but it doesn’t handle content longer than BOX_Wljust() 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).

Suggested change
return "║" + content.ljust(BOX_W) + "║"
truncated_content = content[:BOX_W]
return "║" + truncated_content.ljust(BOX_W) + "║"

Copilot uses AI. Check for mistakes.
Comment on lines 471 to 485
# 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))
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +603 to +607
Args:
no_animation: If True, skip animation and return static banner.

Returns:
The final static banner string (to be used as the shell intro).
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment on lines 7 to 8
import pytest

Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
import pytest

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +46
# 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()
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +106
self.intro = banner.play_animation(no_animation=self._no_animation)
super().cmdloop(intro=self.intro)

Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +467 to +472
parser.add_argument(
"--no-animation",
action="store_true",
default=False,
help="Disable the animated startup banner (show static banner instead)",
)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
romanlutz and others added 3 commits February 28, 2026 07:09
- 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>
Copilot AI review requested due to automatic review settings March 1, 2026 13:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

Comment on lines +107 to +109
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]}..."
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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:]}..."

Copilot uses AI. Check for mistakes.
Comment on lines 62 to 64
context: frontend_core.FrontendCore,
no_animation: bool = False,
):
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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__).

Suggested change
context: frontend_core.FrontendCore,
no_animation: bool = False,
):
*,
context: frontend_core.FrontendCore,
no_animation: bool = False,
) -> None:

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +151
return False
if os.environ.get("PYRIT_NO_ANIMATION"):
return False
# CI environments
return not os.environ.get("CI")
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +639 to +651
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()

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines 34 to +47
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
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
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