From 19358e180e88914835d6196128d197da36c130cd Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Thu, 26 Feb 2026 13:49:53 -0800 Subject: [PATCH 01/20] Add animated ASCII banner with raccoon mascot for PyRIT CLI - 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> --- pyrit/cli/banner.py | 500 +++++++++++++++++++++++++++++ pyrit/cli/pyrit_shell.py | 51 ++- tests/unit/cli/test_banner.py | 170 ++++++++++ tests/unit/cli/test_pyrit_shell.py | 10 +- 4 files changed, 696 insertions(+), 35 deletions(-) create mode 100644 pyrit/cli/banner.py create mode 100644 tests/unit/cli/test_banner.py diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py new file mode 100644 index 0000000000..37f52565d9 --- /dev/null +++ b/pyrit/cli/banner.py @@ -0,0 +1,500 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Animated ASCII banner for PyRIT CLI. + +Displays an animated raccoon mascot revealing the PYRIT logo on shell startup. +Inspired by the GitHub Copilot CLI animated banner approach: + - Frame-based animation with ANSI cursor repositioning + - Semantic color roles with light/dark theme support + - Graceful degradation to static banner when animation isn't supported + +The animation plays for ~2.5 seconds and settles into the familiar static banner. +Press Ctrl+C during animation to skip to the static banner immediately. +""" + +from __future__ import annotations + +import os +import sys +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +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" + + +# ANSI 4-bit color codes (work on virtually all terminals) +ANSI_COLORS = { + "black": "\033[30m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "white": "\033[37m", + "bright_black": "\033[90m", + "bright_red": "\033[91m", + "bright_green": "\033[92m", + "bright_yellow": "\033[93m", + "bright_blue": "\033[94m", + "bright_magenta": "\033[95m", + "bright_cyan": "\033[96m", + "bright_white": "\033[97m", + "bold": "\033[1m", + "reset": "\033[0m", +} + +# Theme mappings: role -> ANSI color name +DARK_THEME: dict[ColorRole, str] = { + ColorRole.BORDER: "cyan", + ColorRole.PYRIT_TEXT: "bright_red", + ColorRole.SUBTITLE: "bright_white", + ColorRole.RACCOON_BODY: "bright_white", + ColorRole.RACCOON_MASK: "bright_black", + ColorRole.RACCOON_EYES: "bright_green", + ColorRole.RACCOON_TAIL: "white", + ColorRole.SPARKLE: "bright_yellow", + ColorRole.COMMANDS: "white", + ColorRole.RESET: "reset", +} + +LIGHT_THEME: dict[ColorRole, str] = { + ColorRole.BORDER: "blue", + ColorRole.PYRIT_TEXT: "red", + ColorRole.SUBTITLE: "black", + ColorRole.RACCOON_BODY: "bright_black", + ColorRole.RACCOON_MASK: "black", + ColorRole.RACCOON_EYES: "green", + ColorRole.RACCOON_TAIL: "bright_black", + ColorRole.SPARKLE: "yellow", + ColorRole.COMMANDS: "bright_black", + ColorRole.RESET: "reset", +} + + +def _get_color(role: ColorRole, theme: dict[ColorRole, str]) -> str: + """Resolve a color role to an ANSI escape sequence.""" + color_name = theme.get(role, "reset") + return ANSI_COLORS.get(color_name, ANSI_COLORS["reset"]) + + +def _detect_theme() -> dict[ColorRole, str]: + """Detect whether terminal is light or dark themed. Defaults to dark.""" + # COLORFGBG is set by some terminals (e.g. xterm): "fg;bg" + colorfgbg = os.environ.get("COLORFGBG", "") + if colorfgbg: + parts = colorfgbg.split(";") + if len(parts) >= 2: + try: + bg = int(parts[-1]) + # bg >= 8 generally means light background + if bg >= 8: + return LIGHT_THEME + except ValueError: + pass + return DARK_THEME + + +@dataclass +class AnimationFrame: + """A single frame of the banner animation.""" + + lines: list[str] + color_map: dict[int, ColorRole] = field(default_factory=dict) + duration: float = 0.15 # seconds to display this frame + + +def can_animate() -> bool: + """Check whether the terminal supports animation.""" + if not sys.stdout.isatty(): + return False + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("PYRIT_NO_ANIMATION"): + return False + # CI environments + if os.environ.get("CI"): + return False + return True + + +# ── Raccoon ASCII art ────────────────────────────────────────────────────────── +# The raccoon is ~12 chars wide × 7 lines tall, designed to fit inside the banner box. + +RACCOON_FRAMES = [ + # Frame 0: raccoon looking right (walking pose 1) + [ + r" /\_/\ ", + r" ( o.o ) ", + r" > ^ < ", + r" /| |\ ~", + r" (_| |_) ", + ], + # Frame 1: raccoon looking right (walking pose 2 - tail up) + [ + r" /\_/\ ~", + r" ( o.o ) ", + r" > ^ < ", + r" /| |\ ", + r" (_| |_) ", + ], + # Frame 2: raccoon winking + [ + r" /\_/\ ", + r" ( -.o ) ", + r" > ^ < ", + r" /| |\ ~", + r" (_| |_) ", + ], + # Frame 3: raccoon celebrating (arms up) + [ + r" /\_/\ ", + r" ( ^.^ ) *", + r" > ^ < ", + r" \| |/ ", + r" (_ _) ", + ], +] + + +# ── PYRIT block letters (same style as existing banner) ──────────────────────── + +PYRIT_LETTERS = [ + "██████╗ ██╗ ██╗██████╗ ██╗████████╗", + "██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝", + "██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ", + "██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ", + "██║ ██║ ██║ ██║██║ ██║ ", + "╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ", +] + +# How many characters to reveal per frame (left to right) +PYRIT_WIDTH = 37 # approximate visible width of PYRIT_LETTERS + + +# ── Static banner (final frame / fallback) ───────────────────────────────────── + +STATIC_BANNER_LINES = [ + "╔══════════════════════════════════════════════════════════════════════════════════════════════╗", + "║ ║", + "║ /\\_/\\ ██████╗ ██╗ ██╗██████╗ ██╗████████╗ ║", + "║ ( o.o ) ██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝ ║", + "║ > ^ < ██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ║", + "║ /| |\\ ~ ██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ║", + "║ (_| |_) ██║ ██║ ██║ ██║██║ ██║ ║", + "║ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ║", + "║ ║", + "║ Python Risk Identification Tool ║", + "║ Interactive Shell ║", + "║ ║", + "╠══════════════════════════════════════════════════════════════════════════════════════════════╣", + "║ ║", + "║ Commands: ║", + "║ • list-scenarios - See all available scenarios ║", + "║ • list-initializers - See all available initializers ║", + "║ • run [opts] - Execute a security scenario ║", + "║ • scenario-history - View your session history ║", + "║ • print-scenario [N] - Display detailed results ║", + "║ • help [command] - Get help on any command ║", + "║ • exit - Quit the shell ║", + "║ ║", + "║ Quick Start: ║", + "║ pyrit> list-scenarios ║", + "║ pyrit> run foundry --initializers openai_objective_target load_default_datasets ║", + "║ ║", + "╚══════════════════════════════════════════════════════════════════════════════════════════════╝", +] + +# Color role for each line index in the static banner +STATIC_COLOR_MAP: dict[int, ColorRole] = { + 0: ColorRole.BORDER, + 1: ColorRole.BORDER, + 2: ColorRole.RACCOON_BODY, # raccoon + PYRIT line + 3: ColorRole.RACCOON_BODY, + 4: ColorRole.RACCOON_BODY, + 5: ColorRole.RACCOON_BODY, + 6: ColorRole.RACCOON_BODY, + 7: ColorRole.RACCOON_BODY, + 8: ColorRole.BORDER, + 9: ColorRole.SUBTITLE, + 10: ColorRole.SUBTITLE, + 11: ColorRole.BORDER, + 12: ColorRole.BORDER, + 13: ColorRole.BORDER, + 14: ColorRole.COMMANDS, + 15: ColorRole.COMMANDS, + 16: ColorRole.COMMANDS, + 17: ColorRole.COMMANDS, + 18: ColorRole.COMMANDS, + 19: ColorRole.COMMANDS, + 20: ColorRole.COMMANDS, + 21: ColorRole.COMMANDS, + 22: ColorRole.BORDER, + 23: ColorRole.COMMANDS, + 24: ColorRole.COMMANDS, + 25: ColorRole.COMMANDS, + 26: ColorRole.BORDER, + 27: ColorRole.BORDER, +} + + +def _build_animation_frames() -> list[AnimationFrame]: + """Build the sequence of animation frames.""" + frames: list[AnimationFrame] = [] + box_w = 94 # inner width of the box (matches static banner) + top = "╔" + "═" * box_w + "╗" + bot = "╚" + "═" * box_w + "╝" + mid = "╠" + "═" * box_w + "╣" + empty = "║" + " " * box_w + "║" + + # ── Phase 1: Raccoon enters from right (4 frames) ────────────────────── + # Raccoon slides in from position 85 → 65 → 45 → 12 (its final x position) + raccoon_positions = [78, 58, 38, 10] + for i, x_pos in enumerate(raccoon_positions): + lines = [top, empty] + raccoon = RACCOON_FRAMES[i % 2] # alternate walking poses + for r_line in raccoon: + padded = " " * x_pos + r_line + # Trim/pad to fit box + content = padded[:box_w].ljust(box_w) + lines.append("║" + content + "║") + # Fill remaining rows of logo area with empty (6 rows for raccoon+PYRIT, then subtitle area) + for _ in range(4): + lines.append(empty) + lines.append(empty) # subtitle placeholder + lines.append(empty) + lines.append(mid) + # commands section (hidden during entry) + for _ in range(13): + lines.append(empty) + lines.append(bot) + + color_map = {j: ColorRole.BORDER for j in range(len(lines))} + for j in range(2, 2 + len(raccoon)): + color_map[j] = ColorRole.RACCOON_BODY + frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.18)) + + # ── Phase 2: PYRIT text reveals left-to-right (4 frames) ────────────── + reveal_steps = [9, 18, 27, PYRIT_WIDTH] + raccoon = RACCOON_FRAMES[0] # standing pose + raccoon_x = 10 + + for step_i, chars_visible in enumerate(reveal_steps): + lines = [top, empty] + for row_i in range(6): + r_part = "" + if row_i < len(raccoon): + r_part = raccoon[row_i] + raccoon_padded = " " * raccoon_x + r_part + raccoon_section = raccoon_padded[:24].ljust(24) + + # Reveal PYRIT letters progressively + if row_i < len(PYRIT_LETTERS): + full_letter_line = PYRIT_LETTERS[row_i] + visible = full_letter_line[:chars_visible] + letter_section = visible.ljust(len(full_letter_line)) + else: + letter_section = "" + + content = (raccoon_section + " " + letter_section)[:box_w].ljust(box_w) + lines.append("║" + content + "║") + + lines.append(empty) + # Subtitle appears on last reveal step + if step_i == len(reveal_steps) - 1: + sub = "Python Risk Identification Tool" + sub_line = " " * 26 + sub + lines.append("║" + sub_line[:box_w].ljust(box_w) + "║") + sub2 = "Interactive Shell" + sub2_line = " " * 32 + sub2 + lines.append("║" + sub2_line[:box_w].ljust(box_w) + "║") + else: + lines.append(empty) + lines.append(empty) + + lines.append(empty) + lines.append(mid) + for _ in range(14): + lines.append(empty) + lines.append(bot) + + color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} + for j in range(2, 8): + color_map[j] = ColorRole.PYRIT_TEXT + color_map[8] = ColorRole.BORDER + color_map[9] = ColorRole.SUBTITLE + color_map[10] = ColorRole.SUBTITLE + frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) + + # ── Phase 3: Raccoon wink + sparkle (2 frames) ──────────────────────── + for sparkle_frame in [2, 3]: # wink, celebrate + raccoon = RACCOON_FRAMES[sparkle_frame] + lines = [top, empty] + for row_i in range(6): + r_part = "" + if row_i < len(raccoon): + r_part = raccoon[row_i] + raccoon_padded = " " * raccoon_x + r_part + raccoon_section = raccoon_padded[:24].ljust(24) + + if row_i < len(PYRIT_LETTERS): + letter_section = PYRIT_LETTERS[row_i] + else: + letter_section = "" + + content = (raccoon_section + " " + letter_section)[:box_w].ljust(box_w) + lines.append("║" + content + "║") + + lines.append(empty) + sub = "Python Risk Identification Tool" + sub_line = " " * 26 + sub + lines.append("║" + sub_line[:box_w].ljust(box_w) + "║") + sub2 = "Interactive Shell" + sub2_line = " " * 32 + sub2 + lines.append("║" + sub2_line[:box_w].ljust(box_w) + "║") + lines.append(empty) + lines.append(mid) + for _ in range(14): + lines.append(empty) + lines.append(bot) + + color_map = {} + for j in range(len(lines)): + if 2 <= j <= 7: + color_map[j] = ColorRole.SPARKLE if sparkle_frame == 3 else ColorRole.RACCOON_BODY + elif j in (9, 10): + color_map[j] = ColorRole.SUBTITLE + else: + color_map[j] = ColorRole.BORDER + frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.25)) + + # ── Phase 4: Commands section reveals (2 frames) ────────────────────── + command_lines = STATIC_BANNER_LINES[14:27] # commands portion + + for cmd_step in [0, 1]: + lines = list(STATIC_BANNER_LINES[:14]) # header through divider + if cmd_step == 0: + # Show first half of commands + lines.extend(command_lines[:7]) + for _ in range(6): + lines.append(empty) + lines.append(bot) + else: + # Show all commands + lines.extend(command_lines) + lines.append(bot) + + color_map = dict(STATIC_COLOR_MAP) + frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) + + return frames + + +def _render_frame(frame: AnimationFrame, theme: dict[ColorRole, str]) -> str: + """Render a single frame with colors applied.""" + reset = _get_color(ColorRole.RESET, theme) + rendered_lines: list[str] = [] + for i, line in enumerate(frame.lines): + role = frame.color_map.get(i, ColorRole.BORDER) + color = _get_color(role, theme) + rendered_lines.append(f"{color}{line}{reset}") + return "\n".join(rendered_lines) + + +def _render_static_banner(theme: dict[ColorRole, str]) -> str: + """Render the static banner with colors.""" + reset = _get_color(ColorRole.RESET, theme) + rendered_lines: list[str] = [] + for i, line in enumerate(STATIC_BANNER_LINES): + role = STATIC_COLOR_MAP.get(i, ColorRole.BORDER) + color = _get_color(role, theme) + rendered_lines.append(f"{color}{line}{reset}") + return "\n".join(rendered_lines) + + +def get_static_banner() -> str: + """Get the static (non-animated) banner string, with colors if supported.""" + if sys.stdout.isatty() and not os.environ.get("NO_COLOR"): + theme = _detect_theme() + return _render_static_banner(theme) + return "\n".join(STATIC_BANNER_LINES) + + +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() + + theme = _detect_theme() + frames = _build_animation_frames() + frame_height = max(len(f.lines) for f in frames) + + try: + # Hide cursor during animation + sys.stdout.write("\033[?25l") + sys.stdout.flush() + + for frame_idx, frame in enumerate(frames): + rendered = _render_frame(frame, theme) + + if frame_idx == 0: + # First frame: just print + sys.stdout.write(rendered) + sys.stdout.flush() + else: + # Move cursor up to overwrite previous frame + sys.stdout.write(f"\033[{frame_height}A") + sys.stdout.write("\r") + sys.stdout.write(rendered) + sys.stdout.flush() + + time.sleep(frame.duration) + + # Final frame: overwrite with the static banner (colored) + sys.stdout.write(f"\033[{frame_height}A") + sys.stdout.write("\r") + static = _render_static_banner(theme) + sys.stdout.write(static) + sys.stdout.write("\n") + sys.stdout.flush() + + 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() + + finally: + # Show cursor again + sys.stdout.write("\033[?25h") + sys.stdout.flush() + + # Return empty string since we already printed the banner + return "" diff --git a/pyrit/cli/pyrit_shell.py b/pyrit/cli/pyrit_shell.py index 2c218f237b..c2c2dae0f6 100644 --- a/pyrit/cli/pyrit_shell.py +++ b/pyrit/cli/pyrit_shell.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from pyrit.models.scenario_result import ScenarioResult -from pyrit.cli import frontend_core +from pyrit.cli import banner, frontend_core class PyRITShell(cmd.Cmd): @@ -41,6 +41,7 @@ class PyRITShell(cmd.Cmd): --database Database type (InMemory, SQLite, AzureSQL) - default for all runs --log-level Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - default for all runs --env-files ... Environment files to load in order - default for all runs + --no-animation Disable the animated startup banner Run Command Options: --initializers ... Built-in initializers to run before the scenario @@ -54,50 +55,23 @@ class PyRITShell(cmd.Cmd): --log-level Override default log level for this run """ - intro = """ -╔══════════════════════════════════════════════════════════════════════════════════════════════╗ -║ ║ -║ ██████╗ ██╗ ██╗██████╗ ██╗████████╗ ║ -║ ██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝ ║ -║ ██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ║ -║ ██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ║ -║ ██║ ██║ ██║ ██║██║ ██║ ║ -║ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ║ -║ ║ -║ Python Risk Identification Tool ║ -║ Interactive Shell ║ -║ ║ -╠══════════════════════════════════════════════════════════════════════════════════════════════╣ -║ ║ -║ Commands: ║ -║ • list-scenarios - See all available scenarios ║ -║ • list-initializers - See all available initializers ║ -║ • run [opts] - Execute a security scenario ║ -║ • scenario-history - View your session history ║ -║ • print-scenario [N] - Display detailed results ║ -║ • help [command] - Get help on any command ║ -║ • exit - Quit the shell ║ -║ ║ -║ Quick Start: ║ -║ pyrit> list-scenarios ║ -║ pyrit> run foundry --initializers openai_objective_target load_default_datasets ║ -║ ║ -╚══════════════════════════════════════════════════════════════════════════════════════════════╝ -""" prompt = "pyrit> " def __init__( self, context: frontend_core.FrontendCore, + no_animation: bool = False, ): """ Initialize the PyRIT shell. Args: context: PyRIT context with loaded registries. + no_animation: If True, skip the animated startup banner. """ super().__init__() self.context = context + self._no_animation = no_animation self.default_database = context._database self.default_log_level: Optional[int] = context._log_level self.default_env_files = context._env_files @@ -122,6 +96,12 @@ def _ensure_initialized(self) -> None: sys.stdout.flush() self._init_complete.wait() + def cmdloop(self, intro: Optional[str] = None) -> None: + """Override cmdloop to play animated banner before starting the REPL.""" + # Play animation (or get static banner) and use result as intro + self.intro = banner.play_animation(no_animation=self._no_animation) + super().cmdloop(intro=self.intro) + def do_list_scenarios(self, arg: str) -> None: """List all available scenarios.""" self._ensure_initialized() @@ -482,6 +462,13 @@ def main() -> int: help="Environment files to load in order (default for all runs, can be overridden per-run)", ) + parser.add_argument( + "--no-animation", + action="store_true", + default=False, + help="Disable the animated startup banner (show static banner instead)", + ) + args = parser.parse_args() # Resolve env files if provided @@ -505,7 +492,7 @@ def main() -> int: # Start shell try: - shell = PyRITShell(context) + shell = PyRITShell(context, no_animation=args.no_animation) shell.cmdloop() return 0 except KeyboardInterrupt: diff --git a/tests/unit/cli/test_banner.py b/tests/unit/cli/test_banner.py new file mode 100644 index 0000000000..f913729f30 --- /dev/null +++ b/tests/unit/cli/test_banner.py @@ -0,0 +1,170 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import os +from unittest.mock import patch + +import pytest + +from pyrit.cli.banner import ( + ANSI_COLORS, + DARK_THEME, + LIGHT_THEME, + STATIC_BANNER_LINES, + ColorRole, + _build_animation_frames, + _detect_theme, + _get_color, + _render_static_banner, + can_animate, + get_static_banner, + play_animation, +) + + +class TestColorRole: + """Tests for color role resolution.""" + + def test_get_color_returns_ansi_code(self) -> None: + color = _get_color(ColorRole.PYRIT_TEXT, DARK_THEME) + assert color == ANSI_COLORS["bright_red"] + + def test_get_color_reset(self) -> None: + color = _get_color(ColorRole.RESET, DARK_THEME) + assert color == ANSI_COLORS["reset"] + + def test_light_theme_differs_from_dark(self) -> None: + dark = _get_color(ColorRole.PYRIT_TEXT, DARK_THEME) + light = _get_color(ColorRole.PYRIT_TEXT, LIGHT_THEME) + assert dark != light + + def test_all_roles_have_mappings(self) -> None: + for role in ColorRole: + assert role in DARK_THEME, f"{role} missing from DARK_THEME" + assert role in LIGHT_THEME, f"{role} missing from LIGHT_THEME" + + +class TestThemeDetection: + """Tests for terminal theme detection.""" + + def test_default_is_dark(self) -> None: + with patch.dict(os.environ, {}, clear=True): + theme = _detect_theme() + assert theme is DARK_THEME + + def test_light_bg_detected(self) -> None: + with patch.dict(os.environ, {"COLORFGBG": "0;15"}): + theme = _detect_theme() + assert theme is LIGHT_THEME + + def test_dark_bg_detected(self) -> None: + with patch.dict(os.environ, {"COLORFGBG": "15;0"}): + theme = _detect_theme() + assert theme is DARK_THEME + + +class TestCanAnimate: + """Tests for animation capability detection.""" + + def test_no_animation_when_not_tty(self) -> None: + with patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = False + assert can_animate() is False + + def test_no_animation_when_no_color(self) -> None: + with patch("sys.stdout") as mock_stdout, patch.dict(os.environ, {"NO_COLOR": "1"}): + mock_stdout.isatty.return_value = True + assert can_animate() is False + + def test_no_animation_when_pyrit_no_animation(self) -> None: + with patch("sys.stdout") as mock_stdout, patch.dict(os.environ, {"PYRIT_NO_ANIMATION": "1"}): + mock_stdout.isatty.return_value = True + assert can_animate() is False + + def test_no_animation_in_ci(self) -> None: + with patch("sys.stdout") as mock_stdout, patch.dict(os.environ, {"CI": "true"}): + mock_stdout.isatty.return_value = True + assert can_animate() is False + + def test_can_animate_in_normal_tty(self) -> None: + with patch("sys.stdout") as mock_stdout, patch.dict( + os.environ, {}, clear=True + ): + mock_stdout.isatty.return_value = True + # Remove env vars that would block animation + os.environ.pop("NO_COLOR", None) + os.environ.pop("PYRIT_NO_ANIMATION", None) + os.environ.pop("CI", None) + assert can_animate() is True + + +class TestAnimationFrames: + """Tests for animation frame generation.""" + + def test_frames_are_generated(self) -> None: + frames = _build_animation_frames() + assert len(frames) > 0 + + def test_all_frames_have_consistent_width(self) -> None: + frames = _build_animation_frames() + for frame in frames: + 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]}..." + + def test_frames_have_positive_duration(self) -> None: + frames = _build_animation_frames() + for frame in frames: + assert frame.duration > 0 + + def test_frames_have_color_maps(self) -> None: + frames = _build_animation_frames() + for frame in frames: + assert len(frame.color_map) > 0 + + +class TestStaticBanner: + """Tests for the static banner.""" + + def test_static_banner_has_pyrit_text(self) -> None: + banner_text = "\n".join(STATIC_BANNER_LINES) + assert "██████╗" in banner_text + assert "PYRIT" not in banner_text # it's in block letters, not plain text + + def test_static_banner_has_raccoon(self) -> None: + banner_text = "\n".join(STATIC_BANNER_LINES) + assert r"/\_/\\" in banner_text or r"/\_/\\" in banner_text or "o.o" in banner_text + + def test_static_banner_has_subtitle(self) -> None: + banner_text = "\n".join(STATIC_BANNER_LINES) + assert "Python Risk Identification Tool" in banner_text + assert "Interactive Shell" in banner_text + + def test_static_banner_has_commands(self) -> None: + banner_text = "\n".join(STATIC_BANNER_LINES) + assert "list-scenarios" in banner_text + assert "run " in banner_text + + def test_render_static_banner_includes_ansi(self) -> None: + rendered = _render_static_banner(DARK_THEME) + assert "\033[" in rendered + + def test_get_static_banner_no_color_in_pipe(self) -> None: + with patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = False + result = get_static_banner() + assert "\033[" not in result + assert "Python Risk Identification Tool" in result + + +class TestPlayAnimation: + """Tests for the play_animation function.""" + + def test_no_animation_returns_static(self) -> None: + result = play_animation(no_animation=True) + assert "Python Risk Identification Tool" in result + + def test_no_animation_when_not_tty(self) -> None: + with patch("pyrit.cli.banner.can_animate", return_value=False): + result = play_animation() + assert "Python Risk Identification Tool" in result diff --git a/tests/unit/cli/test_pyrit_shell.py b/tests/unit/cli/test_pyrit_shell.py index 226ff04974..40cd211f47 100644 --- a/tests/unit/cli/test_pyrit_shell.py +++ b/tests/unit/cli/test_pyrit_shell.py @@ -32,15 +32,19 @@ def test_init(self): mock_context.initialize_async.assert_called_once() 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 @patch("pyrit.cli.frontend_core.print_scenarios_list_async", new_callable=AsyncMock) def test_do_list_scenarios(self, mock_print_scenarios: AsyncMock): From bbe53c36ae919f6be3b23a2fd42d77767afdc103 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 05:02:43 -0800 Subject: [PATCH 02/20] Fix animation cursor repositioning (off-by-one) 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> --- pyrit/cli/banner.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 37f52565d9..28624d3356 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -468,7 +468,9 @@ def play_animation(no_animation: bool = False) -> str: sys.stdout.flush() else: # Move cursor up to overwrite previous frame - sys.stdout.write(f"\033[{frame_height}A") + # rendered has (frame_height - 1) newlines, so cursor is on + # the last line. Move up (frame_height - 1) to reach line 1. + sys.stdout.write(f"\033[{frame_height - 1}A") sys.stdout.write("\r") sys.stdout.write(rendered) sys.stdout.flush() @@ -476,7 +478,7 @@ def play_animation(no_animation: bool = False) -> str: time.sleep(frame.duration) # Final frame: overwrite with the static banner (colored) - sys.stdout.write(f"\033[{frame_height}A") + sys.stdout.write(f"\033[{frame_height - 1}A") sys.stdout.write("\r") static = _render_static_banner(theme) sys.stdout.write(static) From f74ff5bd8cb40897b48ff0fa904ef70152588a11 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 05:16:43 -0800 Subject: [PATCH 03/20] Fix raccoon art and animation scroll drift - 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> --- pyrit/cli/banner.py | 82 ++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 28624d3356..f95c09da5f 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -139,37 +139,37 @@ def can_animate() -> bool: # The raccoon is ~12 chars wide × 7 lines tall, designed to fit inside the banner box. RACCOON_FRAMES = [ - # Frame 0: raccoon looking right (walking pose 1) + # Frame 0: raccoon walking pose 1 — bandit mask (=o.o=), nose (w), striped tail [ - r" /\_/\ ", - r" ( o.o ) ", - r" > ^ < ", - r" /| |\ ~", - r" (_| |_) ", + r" /\___/\ ", + r" ( =o.o= ) ", + r" > -w- < ", + r" /| |\ ~~", + r" (_| |_) ", ], - # Frame 1: raccoon looking right (walking pose 2 - tail up) + # Frame 1: raccoon walking pose 2 — tail up [ - r" /\_/\ ~", - r" ( o.o ) ", - r" > ^ < ", - r" /| |\ ", - r" (_| |_) ", + r" /\___/\ ~~", + r" ( =o.o= ) ", + r" > -w- < ", + r" /| |\ ", + r" (_| |_) ", ], # Frame 2: raccoon winking [ - r" /\_/\ ", - r" ( -.o ) ", - r" > ^ < ", - r" /| |\ ~", - r" (_| |_) ", + r" /\___/\ ", + r" ( =-.o= ) ", + r" > -w- < ", + r" /| |\ ~~", + r" (_| |_) ", ], # Frame 3: raccoon celebrating (arms up) [ - r" /\_/\ ", - r" ( ^.^ ) *", - r" > ^ < ", - r" \| |/ ", - r" (_ _) ", + r" /\___/\ ", + r" ( =^.^= ) * ", + r" > -w- < ", + r" \| |/ ", + r" (_ _) ~~", ], ] @@ -194,12 +194,12 @@ def can_animate() -> bool: STATIC_BANNER_LINES = [ "╔══════════════════════════════════════════════════════════════════════════════════════════════╗", "║ ║", - "║ /\\_/\\ ██████╗ ██╗ ██╗██████╗ ██╗████████╗ ║", - "║ ( o.o ) ██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝ ║", - "║ > ^ < ██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ║", - "║ /| |\\ ~ ██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ║", - "║ (_| |_) ██║ ██║ ██║ ██║██║ ██║ ║", - "║ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ║", + "║ /\\___/\\ ██████╗ ██╗ ██╗██████╗ ██╗████████╗ ║", + "║ ( =o.o= ) ██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝ ║", + "║ > -w- < ██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ║", + "║ /| |\\ ~~ ██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ║", + "║ (_| |_) ██║ ██║ ██║ ██║██║ ██║ ║", + "║ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ║", "║ ║", "║ Python Risk Identification Tool ║", "║ Interactive Shell ║", @@ -457,29 +457,27 @@ def play_animation(no_animation: bool = False) -> str: try: # Hide cursor during animation sys.stdout.write("\033[?25l") + + # Reserve vertical space so the terminal doesn't scroll during animation. + # Print blank lines to push content up, then move cursor back to the top. + sys.stdout.write("\n" * (frame_height - 1)) + sys.stdout.write(f"\033[{frame_height - 1}A") + sys.stdout.write("\r") sys.stdout.flush() for frame_idx, frame in enumerate(frames): rendered = _render_frame(frame, theme) - if frame_idx == 0: - # First frame: just print - sys.stdout.write(rendered) - sys.stdout.flush() - else: - # Move cursor up to overwrite previous frame - # rendered has (frame_height - 1) newlines, so cursor is on - # the last line. Move up (frame_height - 1) to reach line 1. - sys.stdout.write(f"\033[{frame_height - 1}A") - sys.stdout.write("\r") - sys.stdout.write(rendered) - sys.stdout.flush() + if frame_idx > 0: + # Move cursor back to the top of the reserved space + sys.stdout.write(f"\033[{frame_height - 1}A\r") + sys.stdout.write(rendered) + sys.stdout.flush() time.sleep(frame.duration) # Final frame: overwrite with the static banner (colored) - sys.stdout.write(f"\033[{frame_height - 1}A") - sys.stdout.write("\r") + sys.stdout.write(f"\033[{frame_height - 1}A\r") static = _render_static_banner(theme) sys.stdout.write(static) sys.stdout.write("\n") From 3924ed74df8487adcd07f0d3141a9612882b42b5 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 05:34:48 -0800 Subject: [PATCH 04/20] Redesign raccoon with mask and striped tail, fix animation drift - 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> --- pyrit/cli/banner.py | 402 ++++++++++++++++++---------------- pyrit/cli/pyrit_shell.py | 6 +- tests/unit/cli/test_banner.py | 3 +- 3 files changed, 215 insertions(+), 196 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index f95c09da5f..34e4f2001c 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -136,44 +136,63 @@ def can_animate() -> bool: # ── Raccoon ASCII art ────────────────────────────────────────────────────────── -# The raccoon is ~12 chars wide × 7 lines tall, designed to fit inside the banner box. +# Raccoon with bandit mask (=o o=) and bushy striped tail (~~~~~). RACCOON_FRAMES = [ - # Frame 0: raccoon walking pose 1 — bandit mask (=o.o=), nose (w), striped tail + # Frame 0: walking pose 1 — tail trailing behind [ - r" /\___/\ ", - r" ( =o.o= ) ", - r" > -w- < ", - r" /| |\ ~~", - r" (_| |_) ", + " /\\ /\\", + " / \\_/ \\", + " | =o o= |", + " | w |", + " \\ '---' /", + " \\_| |_/", + " | |", + " _/ \\_", + " | |", + " ~~~~~", ], - # Frame 1: raccoon walking pose 2 — tail up + # Frame 1: walking pose 2 — tail up [ - r" /\___/\ ~~", - r" ( =o.o= ) ", - r" > -w- < ", - r" /| |\ ", - r" (_| |_) ", + " /\\ /\\ ~~~~~", + " / \\_/ \\", + " | =o o= |", + " | w |", + " \\ '---' /", + " \\_| |_/", + " | |", + " _/ \\_", + " | |", + " |_____|", ], - # Frame 2: raccoon winking + # Frame 2: winking [ - r" /\___/\ ", - r" ( =-.o= ) ", - r" > -w- < ", - r" /| |\ ~~", - r" (_| |_) ", + " /\\ /\\", + " / \\_/ \\", + " | =- o= |", + " | w |", + " \\ '---' /", + " \\_| |_/", + " | |", + " _/ \\_", + " | |", + " ~~~~~", ], - # Frame 3: raccoon celebrating (arms up) + # Frame 3: celebrating [ - r" /\___/\ ", - r" ( =^.^= ) * ", - r" > -w- < ", - r" \| |/ ", - r" (_ _) ~~", + " /\\ /\\", + " / \\_/ \\", + " | =^ ^= |", + " | w |", + " \\ '---' / *", + " \\_| |_/", + " | |", + " _/ \\_", + " | |", + " ~~~~~", ], ] - # ── PYRIT block letters (same style as existing banner) ──────────────────────── PYRIT_LETTERS = [ @@ -188,220 +207,217 @@ def can_animate() -> bool: # How many characters to reveal per frame (left to right) PYRIT_WIDTH = 37 # approximate visible width of PYRIT_LETTERS +# ── Banner layout constants ──────────────────────────────────────────────────── + +BOX_W = 94 # inner width between ║ chars +RACCOON_COL = 26 # width reserved for raccoon column in header + + +def _box_line(content: str) -> str: + """Wrap content in box border chars, padded to BOX_W.""" + return "║" + content.ljust(BOX_W) + "║" + + +def _empty_line() -> str: + return _box_line("") + # ── Static banner (final frame / fallback) ───────────────────────────────────── -STATIC_BANNER_LINES = [ - "╔══════════════════════════════════════════════════════════════════════════════════════════════╗", - "║ ║", - "║ /\\___/\\ ██████╗ ██╗ ██╗██████╗ ██╗████████╗ ║", - "║ ( =o.o= ) ██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝ ║", - "║ > -w- < ██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ║", - "║ /| |\\ ~~ ██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ║", - "║ (_| |_) ██║ ██║ ██║ ██║██║ ██║ ║", - "║ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ║", - "║ ║", - "║ Python Risk Identification Tool ║", - "║ Interactive Shell ║", - "║ ║", - "╠══════════════════════════════════════════════════════════════════════════════════════════════╣", - "║ ║", - "║ Commands: ║", - "║ • list-scenarios - See all available scenarios ║", - "║ • list-initializers - See all available initializers ║", - "║ • run [opts] - Execute a security scenario ║", - "║ • scenario-history - View your session history ║", - "║ • print-scenario [N] - Display detailed results ║", - "║ • help [command] - Get help on any command ║", - "║ • exit - Quit the shell ║", - "║ ║", - "║ Quick Start: ║", - "║ pyrit> list-scenarios ║", - "║ pyrit> run foundry --initializers openai_objective_target load_default_datasets ║", - "║ ║", - "╚══════════════════════════════════════════════════════════════════════════════════════════════╝", -] -# Color role for each line index in the static banner -STATIC_COLOR_MAP: dict[int, ColorRole] = { - 0: ColorRole.BORDER, - 1: ColorRole.BORDER, - 2: ColorRole.RACCOON_BODY, # raccoon + PYRIT line - 3: ColorRole.RACCOON_BODY, - 4: ColorRole.RACCOON_BODY, - 5: ColorRole.RACCOON_BODY, - 6: ColorRole.RACCOON_BODY, - 7: ColorRole.RACCOON_BODY, - 8: ColorRole.BORDER, - 9: ColorRole.SUBTITLE, - 10: ColorRole.SUBTITLE, - 11: ColorRole.BORDER, - 12: ColorRole.BORDER, - 13: ColorRole.BORDER, - 14: ColorRole.COMMANDS, - 15: ColorRole.COMMANDS, - 16: ColorRole.COMMANDS, - 17: ColorRole.COMMANDS, - 18: ColorRole.COMMANDS, - 19: ColorRole.COMMANDS, - 20: ColorRole.COMMANDS, - 21: ColorRole.COMMANDS, - 22: ColorRole.BORDER, - 23: ColorRole.COMMANDS, - 24: ColorRole.COMMANDS, - 25: ColorRole.COMMANDS, - 26: ColorRole.BORDER, - 27: ColorRole.BORDER, -} +def _build_static_banner() -> tuple[list[str], dict[int, ColorRole]]: + """Build the static banner lines and color map programmatically.""" + raccoon = RACCOON_FRAMES[0] # standing pose + lines: list[str] = [] + color_map: dict[int, ColorRole] = {} + + def add(line: str, role: ColorRole) -> None: + color_map[len(lines)] = role + lines.append(line) + + # Top border + empty + add("╔" + "═" * BOX_W + "╗", ColorRole.BORDER) + add(_empty_line(), ColorRole.BORDER) + + # Header: 10-line raccoon + PYRIT text side by side + # PYRIT starts at raccoon line 1, subtitles at lines 8-9 + for i in range(10): + r_part = (" " + raccoon[i]).ljust(RACCOON_COL) + if 1 <= i <= 6: + p_part = PYRIT_LETTERS[i - 1] + elif i == 8: + p_part = "Python Risk Identification Tool" + elif i == 9: + p_part = " Interactive Shell" + else: + p_part = "" + role = ColorRole.SUBTITLE if i >= 8 else ColorRole.RACCOON_BODY + add(_box_line(r_part + p_part), role) + + add(_empty_line(), ColorRole.BORDER) + + # Mid divider + add("╠" + "═" * BOX_W + "╣", ColorRole.BORDER) + add(_empty_line(), ColorRole.BORDER) + + # Commands section + commands = [ + "Commands:", + " • list-scenarios - See all available scenarios", + " • list-initializers - See all available initializers", + " • run [opts] - Execute a security scenario", + " • scenario-history - View your session history", + " • print-scenario [N] - Display detailed results", + " • help [command] - Get help on any command", + " • exit - Quit the shell", + ] + for cmd in commands: + add(_box_line(" " + cmd), ColorRole.COMMANDS) + + add(_empty_line(), ColorRole.BORDER) + + # Quick start + quick_start = [ + "Quick Start:", + " pyrit> list-scenarios", + " pyrit> run foundry --initializers openai_objective_target load_default_datasets", + ] + for qs in quick_start: + add(_box_line(" " + qs), ColorRole.COMMANDS) + + add(_empty_line(), ColorRole.BORDER) + + # Bottom border + add("╚" + "═" * BOX_W + "╝", ColorRole.BORDER) + + return lines, color_map + + +STATIC_BANNER_LINES, STATIC_COLOR_MAP = _build_static_banner() def _build_animation_frames() -> list[AnimationFrame]: """Build the sequence of animation frames.""" frames: list[AnimationFrame] = [] - box_w = 94 # inner width of the box (matches static banner) - top = "╔" + "═" * box_w + "╗" - bot = "╚" + "═" * box_w + "╝" - mid = "╠" + "═" * box_w + "╣" - empty = "║" + " " * box_w + "║" + target_height = len(STATIC_BANNER_LINES) + top = "╔" + "═" * BOX_W + "╗" + bot = "╚" + "═" * BOX_W + "╝" + mid = "╠" + "═" * BOX_W + "╣" + empty = _empty_line() + + def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: + """Pad frame lines to match static banner height.""" + while len(lines) < target_height - 1: # -1 for bottom border + color_map[len(lines)] = ColorRole.BORDER + lines.append(empty) + color_map[len(lines)] = ColorRole.BORDER + lines.append(bot) # ── Phase 1: Raccoon enters from right (4 frames) ────────────────────── - # Raccoon slides in from position 85 → 65 → 45 → 12 (its final x position) - raccoon_positions = [78, 58, 38, 10] + raccoon_positions = [72, 52, 32, 4] for i, x_pos in enumerate(raccoon_positions): lines = [top, empty] - raccoon = RACCOON_FRAMES[i % 2] # alternate walking poses + color_map: dict[int, ColorRole] = {0: ColorRole.BORDER, 1: ColorRole.BORDER} + raccoon = RACCOON_FRAMES[i % 2] for r_line in raccoon: padded = " " * x_pos + r_line - # Trim/pad to fit box - content = padded[:box_w].ljust(box_w) + content = padded[:BOX_W].ljust(BOX_W) + color_map[len(lines)] = ColorRole.RACCOON_BODY lines.append("║" + content + "║") - # Fill remaining rows of logo area with empty (6 rows for raccoon+PYRIT, then subtitle area) - for _ in range(4): - lines.append(empty) - lines.append(empty) # subtitle placeholder + # Empty line + divider + color_map[len(lines)] = ColorRole.BORDER lines.append(empty) + color_map[len(lines)] = ColorRole.BORDER lines.append(mid) - # commands section (hidden during entry) - for _ in range(13): - lines.append(empty) - lines.append(bot) - - color_map = {j: ColorRole.BORDER for j in range(len(lines))} - for j in range(2, 2 + len(raccoon)): - color_map[j] = ColorRole.RACCOON_BODY + _pad_to_height(lines, color_map) frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.18)) # ── Phase 2: PYRIT text reveals left-to-right (4 frames) ────────────── reveal_steps = [9, 18, 27, PYRIT_WIDTH] - raccoon = RACCOON_FRAMES[0] # standing pose - raccoon_x = 10 + raccoon = RACCOON_FRAMES[0] for step_i, chars_visible in enumerate(reveal_steps): lines = [top, empty] - for row_i in range(6): - r_part = "" - if row_i < len(raccoon): - r_part = raccoon[row_i] - raccoon_padded = " " * raccoon_x + r_part - raccoon_section = raccoon_padded[:24].ljust(24) - - # Reveal PYRIT letters progressively - if row_i < len(PYRIT_LETTERS): - full_letter_line = PYRIT_LETTERS[row_i] - visible = full_letter_line[:chars_visible] - letter_section = visible.ljust(len(full_letter_line)) - else: - letter_section = "" + color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - content = (raccoon_section + " " + letter_section)[:box_w].ljust(box_w) - lines.append("║" + content + "║") + for row_i in range(10): + r_part = (" " + raccoon[row_i]).ljust(RACCOON_COL) + # PYRIT at rows 1-6, subtitles at 8-9 on final step + if 1 <= row_i <= 6: + full_letter = PYRIT_LETTERS[row_i - 1] + visible = full_letter[:chars_visible] + p_part = visible.ljust(len(full_letter)) + elif row_i == 8 and step_i == len(reveal_steps) - 1: + p_part = "Python Risk Identification Tool" + elif row_i == 9 and step_i == len(reveal_steps) - 1: + p_part = " Interactive Shell" + else: + p_part = "" + color_map[len(lines)] = ColorRole.PYRIT_TEXT if 1 <= row_i <= 6 else ColorRole.RACCOON_BODY + lines.append(_box_line(r_part + p_part)) - lines.append(empty) - # Subtitle appears on last reveal step if step_i == len(reveal_steps) - 1: - sub = "Python Risk Identification Tool" - sub_line = " " * 26 + sub - lines.append("║" + sub_line[:box_w].ljust(box_w) + "║") - sub2 = "Interactive Shell" - sub2_line = " " * 32 + sub2 - lines.append("║" + sub2_line[:box_w].ljust(box_w) + "║") - else: - lines.append(empty) - lines.append(empty) + color_map[len(lines) - 2] = ColorRole.SUBTITLE + color_map[len(lines) - 1] = ColorRole.SUBTITLE + color_map[len(lines)] = ColorRole.BORDER lines.append(empty) + color_map[len(lines)] = ColorRole.BORDER lines.append(mid) - for _ in range(14): - lines.append(empty) - lines.append(bot) - - color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - for j in range(2, 8): - color_map[j] = ColorRole.PYRIT_TEXT - color_map[8] = ColorRole.BORDER - color_map[9] = ColorRole.SUBTITLE - color_map[10] = ColorRole.SUBTITLE + _pad_to_height(lines, color_map) frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) # ── Phase 3: Raccoon wink + sparkle (2 frames) ──────────────────────── - for sparkle_frame in [2, 3]: # wink, celebrate - raccoon = RACCOON_FRAMES[sparkle_frame] + for pose_idx in [2, 3]: + raccoon = RACCOON_FRAMES[pose_idx] lines = [top, empty] - for row_i in range(6): - r_part = "" - if row_i < len(raccoon): - r_part = raccoon[row_i] - raccoon_padded = " " * raccoon_x + r_part - raccoon_section = raccoon_padded[:24].ljust(24) - - if row_i < len(PYRIT_LETTERS): - letter_section = PYRIT_LETTERS[row_i] + color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} + base_role = ColorRole.SPARKLE if pose_idx == 3 else ColorRole.RACCOON_BODY + + for row_i in range(10): + r_part = (" " + raccoon[row_i]).ljust(RACCOON_COL) + if 1 <= row_i <= 6: + p_part = PYRIT_LETTERS[row_i - 1] + elif row_i == 8: + p_part = "Python Risk Identification Tool" + elif row_i == 9: + p_part = " Interactive Shell" else: - letter_section = "" - - content = (raccoon_section + " " + letter_section)[:box_w].ljust(box_w) - lines.append("║" + content + "║") + p_part = "" + role = ColorRole.SUBTITLE if row_i >= 8 else base_role + color_map[len(lines)] = role + lines.append(_box_line(r_part + p_part)) + color_map[len(lines)] = ColorRole.BORDER lines.append(empty) - sub = "Python Risk Identification Tool" - sub_line = " " * 26 + sub - lines.append("║" + sub_line[:box_w].ljust(box_w) + "║") - sub2 = "Interactive Shell" - sub2_line = " " * 32 + sub2 - lines.append("║" + sub2_line[:box_w].ljust(box_w) + "║") - lines.append(empty) + color_map[len(lines)] = ColorRole.BORDER lines.append(mid) - for _ in range(14): - lines.append(empty) - lines.append(bot) - - color_map = {} - for j in range(len(lines)): - if 2 <= j <= 7: - color_map[j] = ColorRole.SPARKLE if sparkle_frame == 3 else ColorRole.RACCOON_BODY - elif j in (9, 10): - color_map[j] = ColorRole.SUBTITLE - else: - color_map[j] = ColorRole.BORDER + _pad_to_height(lines, color_map) frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.25)) # ── Phase 4: Commands section reveals (2 frames) ────────────────────── - command_lines = STATIC_BANNER_LINES[14:27] # commands portion + # Use the actual static banner lines, revealing commands section + header_end = next( + i for i, line in enumerate(STATIC_BANNER_LINES) if "╠" in line + ) + 1 # line after mid divider + cmd_start = header_end + cmd_lines = STATIC_BANNER_LINES[cmd_start:] for cmd_step in [0, 1]: - lines = list(STATIC_BANNER_LINES[:14]) # header through divider + lines = list(STATIC_BANNER_LINES[:cmd_start]) + color_map = {i: STATIC_COLOR_MAP.get(i, ColorRole.BORDER) for i in range(len(lines))} + if cmd_step == 0: - # Show first half of commands - lines.extend(command_lines[:7]) - for _ in range(6): - lines.append(empty) - lines.append(bot) + half = len(cmd_lines) // 2 + for cl in cmd_lines[:half]: + color_map[len(lines)] = ColorRole.COMMANDS + lines.append(cl) + _pad_to_height(lines, color_map) else: - # Show all commands - lines.extend(command_lines) - lines.append(bot) + for j, cl in enumerate(cmd_lines): + color_map[len(lines)] = STATIC_COLOR_MAP.get(cmd_start + j, ColorRole.COMMANDS) + lines.append(cl) - color_map = dict(STATIC_COLOR_MAP) frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) return frames diff --git a/pyrit/cli/pyrit_shell.py b/pyrit/cli/pyrit_shell.py index c2c2dae0f6..4d22138387 100644 --- a/pyrit/cli/pyrit_shell.py +++ b/pyrit/cli/pyrit_shell.py @@ -79,7 +79,7 @@ def __init__( # Track scenario execution history: list of (command_string, ScenarioResult) tuples self._scenario_history: list[tuple[str, ScenarioResult]] = [] - # Initialize PyRIT in background thread for faster startup + # Initialize PyRIT in background thread for faster startup. self._init_thread = threading.Thread(target=self._background_init, daemon=True) self._init_complete = threading.Event() self._init_thread.start() @@ -98,7 +98,9 @@ def _ensure_initialized(self) -> None: def cmdloop(self, intro: Optional[str] = None) -> None: """Override cmdloop to play animated banner before starting the REPL.""" - # Play animation (or get static banner) and use result as intro + # Wait for background init to finish BEFORE animation, + # so its log output doesn't interfere with cursor positioning + self._init_complete.wait() self.intro = banner.play_animation(no_animation=self._no_animation) super().cmdloop(intro=self.intro) diff --git a/tests/unit/cli/test_banner.py b/tests/unit/cli/test_banner.py index f913729f30..22d28c3434 100644 --- a/tests/unit/cli/test_banner.py +++ b/tests/unit/cli/test_banner.py @@ -133,7 +133,8 @@ def test_static_banner_has_pyrit_text(self) -> None: def test_static_banner_has_raccoon(self) -> None: banner_text = "\n".join(STATIC_BANNER_LINES) - assert r"/\_/\\" in banner_text or r"/\_/\\" in banner_text or "o.o" in banner_text + assert "=o o=" in banner_text # bandit mask + assert "~~~~~" in banner_text # striped tail def test_static_banner_has_subtitle(self) -> None: banner_text = "\n".join(STATIC_BANNER_LINES) From 4624e11072460ecd88c9dd2f95830f7fdb614c9f Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 05:55:55 -0800 Subject: [PATCH 05/20] Replace ASCII raccoon with inverted braille art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- pyrit/cli/banner.py | 163 +++++++++++++++------------------- tests/unit/cli/test_banner.py | 4 +- 2 files changed, 73 insertions(+), 94 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 34e4f2001c..1d4640c55d 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -135,62 +135,29 @@ def can_animate() -> bool: return True -# ── Raccoon ASCII art ────────────────────────────────────────────────────────── -# Raccoon with bandit mask (=o o=) and bushy striped tail (~~~~~). - -RACCOON_FRAMES = [ - # Frame 0: walking pose 1 — tail trailing behind - [ - " /\\ /\\", - " / \\_/ \\", - " | =o o= |", - " | w |", - " \\ '---' /", - " \\_| |_/", - " | |", - " _/ \\_", - " | |", - " ~~~~~", - ], - # Frame 1: walking pose 2 — tail up - [ - " /\\ /\\ ~~~~~", - " / \\_/ \\", - " | =o o= |", - " | w |", - " \\ '---' /", - " \\_| |_/", - " | |", - " _/ \\_", - " | |", - " |_____|", - ], - # Frame 2: winking - [ - " /\\ /\\", - " / \\_/ \\", - " | =- o= |", - " | w |", - " \\ '---' /", - " \\_| |_/", - " | |", - " _/ \\_", - " | |", - " ~~~~~", - ], - # Frame 3: celebrating - [ - " /\\ /\\", - " / \\_/ \\", - " | =^ ^= |", - " | w |", - " \\ '---' / *", - " \\_| |_/", - " | |", - " _/ \\_", - " | |", - " ~~~~~", - ], +# ── Raccoon braille art ──────────────────────────────────────────────────────── +# High-detail raccoon face rendered in Unicode braille characters. +# The raccoon's bandit mask and features are visible as lighter dot patterns +# against the solid ⣿ background. + +BRAILLE_RACCOON = [ + "⠀⠀⠀⠀⠀⠀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⣼⢻⠈⢑⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢎⠁⠉⣻⡀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⡇⠀⠁⢙⣿⣮⢲⠀⠀⠀⠀⠀⠀⠀⢠⣾⣟⠀⠸⢫⡇⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⣧⢀⠀⠘⣷⣿⠆⠀⠐⠘⠿⠓⠀⠀⢾⣧⠃⠀⠐⣼⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠘⣇⢰⣶⠛⣁⣐⣷⣦⠐⢘⣼⣷⣂⡀⠛⢽⣆⣸⠁⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⣚⣾⡿⢡⣴⣿⣿⣿⣿⠇⠸⣿⣿⣿⣿⣶⡄⠾⣷⣟⡀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠘⣻⠇⣲⡿⠟⠋⢉⠉⢿⠰⠆⡿⠋⠉⠙⠿⣿⣆⡻⣿⣓⠀⠀⠀⠀", + "⠀⠀⠀⣰⢿⣷⠞⢩⡤⠀⠀⠈⢀⣀⠀⡀⣠⡀⢌⡀⢤⣨⠛⢷⣿⣭⠃⠀⠀⠀", + "⠀⠀⠀⣶⠟⠁⠶⠡⠄⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌⠿⠾⠟⢺⣷⣏⠻⣷⠀⠀⠀", + "⠀⠀⠘⠿⣔⠺⢿⣧⡤⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", + "⠀⠀⠀⠐⠻⢿⣶⣅⢀⠐⠀⠙⣒⡃⡀⠄⢘⠉⠋⠁⠆⢀⢼⣿⣿⡟⠋⠁⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣶⣶⣭⣛⠿⡿⣛⣧⣴⣶⣾⡇⠀⠉⠁⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⡿⠙⠓⢹⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢙⢿⣿⠁⢀⠂⠠⢻⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⠓⣵⠚⠀⠀⠀⣀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⠀⣴⣄⣐⣾⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠣⠿⠛⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", ] # ── PYRIT block letters (same style as existing banner) ──────────────────────── @@ -210,7 +177,9 @@ def can_animate() -> bool: # ── Banner layout constants ──────────────────────────────────────────────────── BOX_W = 94 # inner width between ║ chars -RACCOON_COL = 26 # width reserved for raccoon column in header +RACCOON_COL = 32 # width reserved for raccoon column in header (30 + 2 padding) +HEADER_ROWS = 17 # match braille raccoon height +PYRIT_START_ROW = 5 # PYRIT text starts at this row within the header def _box_line(content: str) -> str: @@ -227,7 +196,7 @@ def _empty_line() -> str: def _build_static_banner() -> tuple[list[str], dict[int, ColorRole]]: """Build the static banner lines and color map programmatically.""" - raccoon = RACCOON_FRAMES[0] # standing pose + raccoon = BRAILLE_RACCOON lines: list[str] = [] color_map: dict[int, ColorRole] = {} @@ -239,19 +208,22 @@ def add(line: str, role: ColorRole) -> None: add("╔" + "═" * BOX_W + "╗", ColorRole.BORDER) add(_empty_line(), ColorRole.BORDER) - # Header: 10-line raccoon + PYRIT text side by side - # PYRIT starts at raccoon line 1, subtitles at lines 8-9 - for i in range(10): - r_part = (" " + raccoon[i]).ljust(RACCOON_COL) - if 1 <= i <= 6: - p_part = PYRIT_LETTERS[i - 1] - elif i == 8: + # Header: braille raccoon + PYRIT text side by side + # PYRIT text at rows PYRIT_START_ROW..+5, subtitles 2 rows after PYRIT + subtitle_row_1 = PYRIT_START_ROW + len(PYRIT_LETTERS) + 1 + subtitle_row_2 = subtitle_row_1 + 1 + for i in range(HEADER_ROWS): + r_part = (" " + raccoon[i] + " ").ljust(RACCOON_COL) + pyrit_idx = i - PYRIT_START_ROW + if 0 <= pyrit_idx < len(PYRIT_LETTERS): + p_part = PYRIT_LETTERS[pyrit_idx] + elif i == subtitle_row_1: p_part = "Python Risk Identification Tool" - elif i == 9: + elif i == subtitle_row_2: p_part = " Interactive Shell" else: p_part = "" - role = ColorRole.SUBTITLE if i >= 8 else ColorRole.RACCOON_BODY + role = ColorRole.SUBTITLE if i in (subtitle_row_1, subtitle_row_2) else ColorRole.RACCOON_BODY add(_box_line(r_part + p_part), role) add(_empty_line(), ColorRole.BORDER) @@ -314,11 +286,12 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: lines.append(bot) # ── Phase 1: Raccoon enters from right (4 frames) ────────────────────── - raccoon_positions = [72, 52, 32, 4] + raccoon = BRAILLE_RACCOON + raccoon_w = max(len(line) for line in raccoon) + raccoon_positions = [BOX_W - raccoon_w, (BOX_W - raccoon_w) * 2 // 3, (BOX_W - raccoon_w) // 3, 1] for i, x_pos in enumerate(raccoon_positions): lines = [top, empty] color_map: dict[int, ColorRole] = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - raccoon = RACCOON_FRAMES[i % 2] for r_line in raccoon: padded = " " * x_pos + r_line content = padded[:BOX_W].ljust(BOX_W) @@ -334,31 +307,37 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: # ── Phase 2: PYRIT text reveals left-to-right (4 frames) ────────────── reveal_steps = [9, 18, 27, PYRIT_WIDTH] - raccoon = RACCOON_FRAMES[0] + subtitle_row_1 = PYRIT_START_ROW + len(PYRIT_LETTERS) + 1 + subtitle_row_2 = subtitle_row_1 + 1 for step_i, chars_visible in enumerate(reveal_steps): lines = [top, empty] color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - for row_i in range(10): - r_part = (" " + raccoon[row_i]).ljust(RACCOON_COL) - # PYRIT at rows 1-6, subtitles at 8-9 on final step - if 1 <= row_i <= 6: - full_letter = PYRIT_LETTERS[row_i - 1] + for row_i in range(HEADER_ROWS): + r_part = (" " + raccoon[row_i] + " ").ljust(RACCOON_COL) + pyrit_idx = row_i - PYRIT_START_ROW + if 0 <= pyrit_idx < len(PYRIT_LETTERS): + full_letter = PYRIT_LETTERS[pyrit_idx] visible = full_letter[:chars_visible] p_part = visible.ljust(len(full_letter)) - elif row_i == 8 and step_i == len(reveal_steps) - 1: + elif row_i == subtitle_row_1 and step_i == len(reveal_steps) - 1: p_part = "Python Risk Identification Tool" - elif row_i == 9 and step_i == len(reveal_steps) - 1: + elif row_i == subtitle_row_2 and step_i == len(reveal_steps) - 1: p_part = " Interactive Shell" else: p_part = "" - color_map[len(lines)] = ColorRole.PYRIT_TEXT if 1 <= row_i <= 6 else ColorRole.RACCOON_BODY + role = ColorRole.PYRIT_TEXT if 0 <= pyrit_idx < len(PYRIT_LETTERS) else ColorRole.RACCOON_BODY + color_map[len(lines)] = role lines.append(_box_line(r_part + p_part)) if step_i == len(reveal_steps) - 1: - color_map[len(lines) - 2] = ColorRole.SUBTITLE - color_map[len(lines) - 1] = ColorRole.SUBTITLE + # Fix subtitle colors on final reveal + for line_idx in range(len(lines)): + if line_idx >= 2: + row_in_header = line_idx - 2 + if row_in_header in (subtitle_row_1, subtitle_row_2): + color_map[line_idx] = ColorRole.SUBTITLE color_map[len(lines)] = ColorRole.BORDER lines.append(empty) @@ -367,24 +346,24 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: _pad_to_height(lines, color_map) frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) - # ── Phase 3: Raccoon wink + sparkle (2 frames) ──────────────────────── - for pose_idx in [2, 3]: - raccoon = RACCOON_FRAMES[pose_idx] + # ── Phase 3: Sparkle celebration (2 frames) ─────────────────────────── + for sparkle_idx in range(2): lines = [top, empty] color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - base_role = ColorRole.SPARKLE if pose_idx == 3 else ColorRole.RACCOON_BODY - - for row_i in range(10): - r_part = (" " + raccoon[row_i]).ljust(RACCOON_COL) - if 1 <= row_i <= 6: - p_part = PYRIT_LETTERS[row_i - 1] - elif row_i == 8: + base_role = ColorRole.SPARKLE if sparkle_idx == 1 else ColorRole.RACCOON_BODY + + for row_i in range(HEADER_ROWS): + r_part = (" " + raccoon[row_i] + " ").ljust(RACCOON_COL) + pyrit_idx = row_i - PYRIT_START_ROW + if 0 <= pyrit_idx < len(PYRIT_LETTERS): + p_part = PYRIT_LETTERS[pyrit_idx] + elif row_i == subtitle_row_1: p_part = "Python Risk Identification Tool" - elif row_i == 9: + elif row_i == subtitle_row_2: p_part = " Interactive Shell" else: p_part = "" - role = ColorRole.SUBTITLE if row_i >= 8 else base_role + role = ColorRole.SUBTITLE if row_i in (subtitle_row_1, subtitle_row_2) else base_role color_map[len(lines)] = role lines.append(_box_line(r_part + p_part)) diff --git a/tests/unit/cli/test_banner.py b/tests/unit/cli/test_banner.py index 22d28c3434..36acd64d66 100644 --- a/tests/unit/cli/test_banner.py +++ b/tests/unit/cli/test_banner.py @@ -133,8 +133,8 @@ def test_static_banner_has_pyrit_text(self) -> None: def test_static_banner_has_raccoon(self) -> None: banner_text = "\n".join(STATIC_BANNER_LINES) - assert "=o o=" in banner_text # bandit mask - assert "~~~~~" in banner_text # striped tail + assert "⣿" in banner_text # braille raccoon art + assert "⠿" in banner_text # raccoon mask detail def test_static_banner_has_subtitle(self) -> None: banner_text = "\n".join(STATIC_BANNER_LINES) From a7df637595c37f24dc5e1e4717dad9657acc7ad9 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 06:02:37 -0800 Subject: [PATCH 06/20] Simplify raccoon art and clean up chin bottom dots 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> --- pyrit/cli/banner.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 1d4640c55d..de194ad17a 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -141,7 +141,7 @@ def can_animate() -> bool: # against the solid ⣿ background. BRAILLE_RACCOON = [ - "⠀⠀⠀⠀⠀⠀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⣀⣀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⣼⢻⠈⢑⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢎⠁⠉⣻⡀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⡇⠀⠁⢙⣿⣮⢲⠀⠀⠀⠀⠀⠀⠀⢠⣾⣟⠀⠸⢫⡇⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⣧⢀⠀⠘⣷⣿⠆⠀⠐⠘⠿⠓⠀⠀⢾⣧⠃⠀⠐⣼⠀⠀⠀⠀⠀", @@ -152,12 +152,7 @@ def can_animate() -> bool: "⠀⠀⠀⣶⠟⠁⠶⠡⠄⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌⠿⠾⠟⢺⣷⣏⠻⣷⠀⠀⠀", "⠀⠀⠘⠿⣔⠺⢿⣧⡤⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", "⠀⠀⠀⠐⠻⢿⣶⣅⢀⠐⠀⠙⣒⡃⡀⠄⢘⠉⠋⠁⠆⢀⢼⣿⣿⡟⠋⠁⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣶⣶⣭⣛⠿⡿⣛⣧⣴⣶⣾⡇⠀⠉⠁⠀⠀⠀⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⡿⠙⠓⢹⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢙⢿⣿⠁⢀⠂⠠⢻⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⠓⣵⠚⠀⠀⠀⣀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⠀⣴⣄⣐⣾⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠣⠿⠛⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀ ⠭⠛⠿⠿⠛⠧ ⠀⠉⠁⠀⠀⠀⠀⠀", ] # ── PYRIT block letters (same style as existing banner) ──────────────────────── @@ -178,8 +173,8 @@ def can_animate() -> bool: BOX_W = 94 # inner width between ║ chars RACCOON_COL = 32 # width reserved for raccoon column in header (30 + 2 padding) -HEADER_ROWS = 17 # match braille raccoon height -PYRIT_START_ROW = 5 # PYRIT text starts at this row within the header +HEADER_ROWS = 12 # match braille raccoon height +PYRIT_START_ROW = 3 # PYRIT text starts at this row within the header def _box_line(content: str) -> str: From 2fd87b4342c5b07bc236a9ff94e40fe9409a6e38 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 06:16:14 -0800 Subject: [PATCH 07/20] Fix raccoon color inconsistency and layout 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> --- pyrit/cli/banner.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index de194ad17a..d128554fe0 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -148,22 +148,23 @@ def can_animate() -> bool: "⠀⠀⠀⠀⠀⠘⣇⢰⣶⠛⣁⣐⣷⣦⠐⢘⣼⣷⣂⡀⠛⢽⣆⣸⠁⠀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⣚⣾⡿⢡⣴⣿⣿⣿⣿⠇⠸⣿⣿⣿⣿⣶⡄⠾⣷⣟⡀⠀⠀⠀⠀", "⠀⠀⠀⠀⠘⣻⠇⣲⡿⠟⠋⢉⠉⢿⠰⠆⡿⠋⠉⠙⠿⣿⣆⡻⣿⣓⠀⠀⠀⠀", - "⠀⠀⠀⣰⢿⣷⠞⢩⡤⠀⠀⠈⢀⣀⠀⡀⣠⡀⢌⡀⢤⣨⠛⢷⣿⣭⠃⠀⠀⠀", - "⠀⠀⠀⣶⠟⠁⠶⠡⠄⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌⠿⠾⠟⢺⣷⣏⠻⣷⠀⠀⠀", - "⠀⠀⠘⠿⣔⠺⢿⣧⡤⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", + "⠀⠀⠀⣰⢿⣷⠞⢩ ⠀⠀⠈⢀⣀⠀⡀⣠⡀⠈ ⣨⠛⢷⣿⣭⠃⠀⠀⠀", + "⠀⠀⠀⣶⠟⠁⠶ ⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌ ⠻⣷⠀⠀⠀", + "⠀⠀⠘⠿⣔⠺ ⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", "⠀⠀⠀⠐⠻⢿⣶⣅⢀⠐⠀⠙⣒⡃⡀⠄⢘⠉⠋⠁⠆⢀⢼⣿⣿⡟⠋⠁⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀ ⠭⠛⠿⠿⠛⠧ ⠀⠉⠁⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀ ⠭⠛⠿⠿⠛⠧ ⠀⠀⠀⠀⠀⠀⠀⠀", ] # ── PYRIT block letters (same style as existing banner) ──────────────────────── PYRIT_LETTERS = [ - "██████╗ ██╗ ██╗██████╗ ██╗████████╗", - "██╔══██╗╚██╗ ██╔╝██╔══██╗██║╚══██╔══╝", - "██████╔╝ ╚████╔╝ ██████╔╝██║ ██║ ", - "██╔═══╝ ╚██╔╝ ██╔══██╗██║ ██║ ", - "██║ ██║ ██║ ██║██║ ██║ ", - "╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ", + "██████╗ ██████╗ ██╗████████╗", + "██╔══██╗██╗ ██╗██╔══██╗██║╚══██╔══╝", + "██████╔╝╚██╗ ██╔╝██████╔╝██║ ██║ ", + "██╔═══╝ ╚████╔╝ ██╔══██╗██║ ██║ ", + "██║ ╚██╔╝ ██║ ██║██║ ██║ ", + "╚═╝ ██║ ╚═╝ ╚═╝╚═╝ ╚═╝ ", + " ╚═╝ ", ] # How many characters to reveal per frame (left to right) @@ -174,7 +175,7 @@ def can_animate() -> bool: BOX_W = 94 # inner width between ║ chars RACCOON_COL = 32 # width reserved for raccoon column in header (30 + 2 padding) HEADER_ROWS = 12 # match braille raccoon height -PYRIT_START_ROW = 3 # PYRIT text starts at this row within the header +PYRIT_START_ROW = 2 # PYRIT text starts at this row within the header def _box_line(content: str) -> str: @@ -322,8 +323,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: p_part = " Interactive Shell" else: p_part = "" - role = ColorRole.PYRIT_TEXT if 0 <= pyrit_idx < len(PYRIT_LETTERS) else ColorRole.RACCOON_BODY - color_map[len(lines)] = role + color_map[len(lines)] = ColorRole.RACCOON_BODY lines.append(_box_line(r_part + p_part)) if step_i == len(reveal_steps) - 1: From b2220fb425d0407372df96134ba14f050d63f09c Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 06:28:05 -0800 Subject: [PATCH 08/20] Fix raccoon spacing, color consistency, and add striped tail - 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> --- pyrit/cli/banner.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index d128554fe0..8af5ebced6 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -141,18 +141,18 @@ def can_animate() -> bool: # against the solid ⣿ background. BRAILLE_RACCOON = [ - "⠀⠀⠀⠀⠀⠀⣀⣀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⣼⢻⠈⢑⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢎⠁⠉⣻⡀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⡇⠀⠁⢙⣿⣮⢲⠀⠀⠀⠀⠀⠀⠀⢠⣾⣟⠀⠸⢫⡇⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⣧⢀⠀⠘⣷⣿⠆⠀⠐⠘⠿⠓⠀⠀⢾⣧⠃⠀⠐⣼⠀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⠘⣇⢰⣶⠛⣁⣐⣷⣦⠐⢘⣼⣷⣂⡀⠛⢽⣆⣸⠁⠀⠀⠀⠀⠀", "⠀⠀⠀⠀⠀⣚⣾⡿⢡⣴⣿⣿⣿⣿⠇⠸⣿⣿⣿⣿⣶⡄⠾⣷⣟⡀⠀⠀⠀⠀", "⠀⠀⠀⠀⠘⣻⠇⣲⡿⠟⠋⢉⠉⢿⠰⠆⡿⠋⠉⠙⠿⣿⣆⡻⣿⣓⠀⠀⠀⠀", - "⠀⠀⠀⣰⢿⣷⠞⢩ ⠀⠀⠈⢀⣀⠀⡀⣠⡀⠈ ⣨⠛⢷⣿⣭⠃⠀⠀⠀", - "⠀⠀⠀⣶⠟⠁⠶ ⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌ ⠻⣷⠀⠀⠀", - "⠀⠀⠘⠿⣔⠺ ⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", + "⠀⠀⠀⣰⢿⣷⠞⢩⠀⠀⠀⠈⢀⣀⠀⡀⣠⡀⠈⠀⠀⣨⠛⢷⣿⣭⠃⠀⠀⠀", + "⠀⠀⠀⣶⠟⠁⠶⠀⠀⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌⠀⠀⠀⠀⠀⠀⠻⣷⠀⠀⠀", + "⠀⠀⠘⠿⣔⠺⠀⠀⠀⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", "⠀⠀⠀⠐⠻⢿⣶⣅⢀⠐⠀⠙⣒⡃⡀⠄⢘⠉⠋⠁⠆⢀⢼⣿⣿⡟⠋⠁⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀ ⠭⠛⠿⠿⠛⠧ ⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠭⠛⠿⠿⠛⠧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", ] # ── PYRIT block letters (same style as existing banner) ──────────────────────── @@ -219,16 +219,18 @@ def add(line: str, role: ColorRole) -> None: p_part = " Interactive Shell" else: p_part = "" - role = ColorRole.SUBTITLE if i in (subtitle_row_1, subtitle_row_2) else ColorRole.RACCOON_BODY + role = ColorRole.RACCOON_BODY add(_box_line(r_part + p_part), role) add(_empty_line(), ColorRole.BORDER) - # Mid divider - add("╠" + "═" * BOX_W + "╣", ColorRole.BORDER) - add(_empty_line(), ColorRole.BORDER) + # Mid divider (with tail attachment point) + tail_col = 82 + tail = ["║", "│", "║", "│", "║", "│", "╲", " ~"] + divider_content = "═" * tail_col + "╤" + "═" * (BOX_W - tail_col - 1) + add("╠" + divider_content + "╣", ColorRole.BORDER) - # Commands section + # Commands section with striped tail hanging from divider commands = [ "Commands:", " • list-scenarios - See all available scenarios", @@ -239,8 +241,17 @@ def add(line: str, role: ColorRole) -> None: " • help [command] - Get help on any command", " • exit - Quit the shell", ] + cmd_section: list[tuple[str, ColorRole]] = [ + ("", ColorRole.BORDER), # empty line after divider + ] for cmd in commands: - add(_box_line(" " + cmd), ColorRole.COMMANDS) + cmd_section.append((" " + cmd, ColorRole.COMMANDS)) + cmd_section.append(("", ColorRole.BORDER)) # empty line after commands + + for i, (content, cmd_role) in enumerate(cmd_section): + if i < len(tail): + content = content.ljust(tail_col) + tail[i] + add(_box_line(content), cmd_role) add(_empty_line(), ColorRole.BORDER) From 82973d8bdc674897e8d0d0783ee1ae595e6817c2 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 06:32:10 -0800 Subject: [PATCH 09/20] Fix subtitle color in animation, chin offset, and redesign tail - 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> --- pyrit/cli/banner.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 8af5ebced6..11ebd32f0f 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -152,7 +152,7 @@ def can_animate() -> bool: "⠀⠀⠀⣶⠟⠁⠶⠀⠀⠀⠀⣠⣾⡟⠘⠃⢻⣿⣌⠀⠀⠀⠀⠀⠀⠻⣷⠀⠀⠀", "⠀⠀⠘⠿⣔⠺⠀⠀⠀⠀⢰⣿⣿⡀⠘⠀⢀⣿⣿⡆⡂⠀⡈⠡⠜⣙⣿⠇⠀⠀", "⠀⠀⠀⠐⠻⢿⣶⣅⢀⠐⠀⠙⣒⡃⡀⠄⢘⠉⠋⠁⠆⢀⢼⣿⣿⡟⠋⠁⠀⠀", - "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠭⠛⠿⠿⠛⠧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠭⠛⠿⠿⠛⠧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀", ] # ── PYRIT block letters (same style as existing banner) ──────────────────────── @@ -225,10 +225,18 @@ def add(line: str, role: ColorRole) -> None: add(_empty_line(), ColorRole.BORDER) # Mid divider (with tail attachment point) - tail_col = 82 - tail = ["║", "│", "║", "│", "║", "│", "╲", " ~"] - divider_content = "═" * tail_col + "╤" + "═" * (BOX_W - tail_col - 1) - add("╠" + divider_content + "╣", ColorRole.BORDER) + tail_col = 80 + tail = [ + "⣿⣿⣿⣿⣿⣿", # dark stripe (wide) + "⠒⠒⠒⠒⠒⠒", # light stripe + "⠀⣿⣿⣿⣿⠀", # dark stripe (narrower) + "⠀⠒⠒⠒⠒⠀", # light stripe + "⠀⠀⣿⣿⠀⠀", # dark stripe (tapered) + "⠀⠀⠒⠒⠀⠀", # light stripe + "⠀⠀⠀⣷⠀⠀", # dark tip + "⠀⠀⠀⠁⠀⠀", # very tip + ] + add("╠" + "═" * BOX_W + "╣", ColorRole.BORDER) # Commands section with striped tail hanging from divider commands = [ @@ -337,14 +345,6 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: color_map[len(lines)] = ColorRole.RACCOON_BODY lines.append(_box_line(r_part + p_part)) - if step_i == len(reveal_steps) - 1: - # Fix subtitle colors on final reveal - for line_idx in range(len(lines)): - if line_idx >= 2: - row_in_header = line_idx - 2 - if row_in_header in (subtitle_row_1, subtitle_row_2): - color_map[line_idx] = ColorRole.SUBTITLE - color_map[len(lines)] = ColorRole.BORDER lines.append(empty) color_map[len(lines)] = ColorRole.BORDER @@ -369,8 +369,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: p_part = " Interactive Shell" else: p_part = "" - role = ColorRole.SUBTITLE if row_i in (subtitle_row_1, subtitle_row_2) else base_role - color_map[len(lines)] = role + color_map[len(lines)] = base_role lines.append(_box_line(r_part + p_part)) color_map[len(lines)] = ColorRole.BORDER From e8e9bbea9fba8743c494baff3e9c1131d22ae833 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 06:48:12 -0800 Subject: [PATCH 10/20] Make tail bushier: 10 lines with tapering braille stripes 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> --- pyrit/cli/banner.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 11ebd32f0f..ea2ee5dbb3 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -227,14 +227,16 @@ def add(line: str, role: ColorRole) -> None: # Mid divider (with tail attachment point) tail_col = 80 tail = [ - "⣿⣿⣿⣿⣿⣿", # dark stripe (wide) - "⠒⠒⠒⠒⠒⠒", # light stripe - "⠀⣿⣿⣿⣿⠀", # dark stripe (narrower) - "⠀⠒⠒⠒⠒⠀", # light stripe - "⠀⠀⣿⣿⠀⠀", # dark stripe (tapered) - "⠀⠀⠒⠒⠀⠀", # light stripe - "⠀⠀⠀⣷⠀⠀", # dark tip - "⠀⠀⠀⠁⠀⠀", # very tip + "⣿⣿⣿⣿⣿⠀", # w=5 (dark stripe) + "⠉⠀⠀⠀⠀⠉", # w=6 (light edges) + "⣿⣿⣿⣿⣿⣿", # w=6 (dark stripe) + "⠉⠀⠀⠀⠉⠀", # w=5 (light edges) + "⣿⣿⣿⣿⣿⠀", # w=5 (dark stripe) + "⠀⠉⠀⠀⠉⠀", # w=4 (light edges) + "⠀⣿⣿⣿⣿⠀", # w=4 (dark stripe) + "⠀⠉⠀⠉⠀⠀", # w=3 (light edges) + "⠀⠀⣿⣿⠀⠀", # w=2 (dark stripe) + "⠀⠀⠉⠀⠀⠀", # w=1 (light edges / tip) ] add("╠" + "═" * BOX_W + "╣", ColorRole.BORDER) From 4f30fcda10f77b298a31f6b4615ee46d4a2a9cee Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 06:58:08 -0800 Subject: [PATCH 11/20] Make tail wider, curling, with vertical delimiters 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> --- pyrit/cli/banner.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index ea2ee5dbb3..0ece6abbe5 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -225,18 +225,18 @@ def add(line: str, role: ColorRole) -> None: add(_empty_line(), ColorRole.BORDER) # Mid divider (with tail attachment point) - tail_col = 80 + tail_col = 77 tail = [ - "⣿⣿⣿⣿⣿⠀", # w=5 (dark stripe) - "⠉⠀⠀⠀⠀⠉", # w=6 (light edges) - "⣿⣿⣿⣿⣿⣿", # w=6 (dark stripe) - "⠉⠀⠀⠀⠉⠀", # w=5 (light edges) - "⣿⣿⣿⣿⣿⠀", # w=5 (dark stripe) - "⠀⠉⠀⠀⠉⠀", # w=4 (light edges) - "⠀⣿⣿⣿⣿⠀", # w=4 (dark stripe) - "⠀⠉⠀⠉⠀⠀", # w=3 (light edges) - "⠀⠀⣿⣿⠀⠀", # w=2 (dark stripe) - "⠀⠀⠉⠀⠀⠀", # w=1 (light edges / tip) + "⣿⣿⣿⣿⣿⣿⣿⣿⠀", # w=8 (dark stripe) + "⠇⠀⠀⠀⠀⠀⠀⠀⠸", # w=9 (light edges) + "⣿⣿⣿⣿⣿⣿⣿⣿⣿", # w=9 (dark stripe) + "⠀⠇⠀⠀⠀⠀⠀⠀⠸", # w=8 (light edges, curl +1) + "⠀⣿⣿⣿⣿⣿⣿⣿⣿", # w=8 (dark stripe, curl +1) + "⠀⠀⠇⠀⠀⠀⠀⠸⠀", # w=6 (light edges, curl +2) + "⠀⠀⣿⣿⣿⣿⣿⣿⠀", # w=6 (dark stripe, curl +2) + "⠀⠀⠀⠇⠀⠀⠀⠸⠀", # w=5 (light edges, curl +3) + "⠀⠀⠀⣿⣿⣿⠀⠀⠀", # w=3 (dark stripe, curl +3) + "⠀⠀⠀⠀⠇⠸⠀⠀⠀", # w=2 (light edges / tip) ] add("╠" + "═" * BOX_W + "╣", ColorRole.BORDER) From 72c10db469dfc27b389a86219e15634475016d08 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 09:52:31 -0800 Subject: [PATCH 12/20] Fix mixed blue/white tail colors 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> --- pyrit/cli/banner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 0ece6abbe5..722053921f 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -261,6 +261,7 @@ def add(line: str, role: ColorRole) -> None: for i, (content, cmd_role) in enumerate(cmd_section): if i < len(tail): content = content.ljust(tail_col) + tail[i] + cmd_role = ColorRole.COMMANDS # tail should match commands color add(_box_line(content), cmd_role) add(_empty_line(), ColorRole.BORDER) From e3e384ad39ff9e9166291dbb25eb5b8b8b05b323 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 10:50:13 -0800 Subject: [PATCH 13/20] Make tail curl properly with S-curve shape 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> --- pyrit/cli/banner.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 722053921f..85d5d57a63 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -226,17 +226,19 @@ def add(line: str, role: ColorRole) -> None: # Mid divider (with tail attachment point) tail_col = 77 + # Curling tail: curves right then sweeps back left at the tip + # offsets: 0→1→2→3→3→3→2→1→0 creates the curl tail = [ - "⣿⣿⣿⣿⣿⣿⣿⣿⠀", # w=8 (dark stripe) - "⠇⠀⠀⠀⠀⠀⠀⠀⠸", # w=9 (light edges) - "⣿⣿⣿⣿⣿⣿⣿⣿⣿", # w=9 (dark stripe) - "⠀⠇⠀⠀⠀⠀⠀⠀⠸", # w=8 (light edges, curl +1) - "⠀⣿⣿⣿⣿⣿⣿⣿⣿", # w=8 (dark stripe, curl +1) - "⠀⠀⠇⠀⠀⠀⠀⠸⠀", # w=6 (light edges, curl +2) - "⠀⠀⣿⣿⣿⣿⣿⣿⠀", # w=6 (dark stripe, curl +2) - "⠀⠀⠀⠇⠀⠀⠀⠸⠀", # w=5 (light edges, curl +3) - "⠀⠀⠀⣿⣿⣿⠀⠀⠀", # w=3 (dark stripe, curl +3) - "⠀⠀⠀⠀⠇⠸⠀⠀⠀", # w=2 (light edges / tip) + "⣿⣿⣿⣿⣿⣿⣿⣿⠀", # off=0 w=8 (dark) + "⠇⠀⠀⠀⠀⠀⠀⠀⠸", # off=0 w=9 (light edges) + "⠀⣿⣿⣿⣿⣿⣿⣿⣿", # off=1 w=8 (dark, curving right) + "⠀⠀⠇⠀⠀⠀⠀⠀⠸", # off=2 w=7 (light edges) + "⠀⠀⠀⣿⣿⣿⣿⣿⣿", # off=3 w=6 (dark, peak of curl) + "⠀⠀⠀⠇⠀⠀⠀⠀⠸", # off=3 w=6 (light edges) + "⠀⠀⠀⣿⣿⣿⣿⣿⠀", # off=3 w=5 (dark, starting back) + "⠀⠀⠇⠀⠀⠸⠀⠀⠀", # off=2 w=4 (light edges, curling back) + "⠀⣿⣿⣿⠀⠀⠀⠀⠀", # off=1 w=3 (dark, curling back) + "⠇⠸⠀⠀⠀⠀⠀⠀⠀", # off=0 w=2 (light edges / tip) ] add("╠" + "═" * BOX_W + "╣", ColorRole.BORDER) From 1a1aca51167a5027bd2acbbfb73d64cc21a2639d Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 12:51:07 -0800 Subject: [PATCH 14/20] Add per-segment coloring, vibrant theme, and richer animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- pyrit/cli/banner.py | 217 +++++++++++++++++++++++++++------- tests/unit/cli/test_banner.py | 2 +- 2 files changed, 178 insertions(+), 41 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 85d5d57a63..2b2bf869a6 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -64,12 +64,12 @@ class ColorRole(Enum): # Theme mappings: role -> ANSI color name DARK_THEME: dict[ColorRole, str] = { ColorRole.BORDER: "cyan", - ColorRole.PYRIT_TEXT: "bright_red", + ColorRole.PYRIT_TEXT: "bright_cyan", ColorRole.SUBTITLE: "bright_white", - ColorRole.RACCOON_BODY: "bright_white", + ColorRole.RACCOON_BODY: "bright_magenta", ColorRole.RACCOON_MASK: "bright_black", ColorRole.RACCOON_EYES: "bright_green", - ColorRole.RACCOON_TAIL: "white", + ColorRole.RACCOON_TAIL: "bright_magenta", ColorRole.SPARKLE: "bright_yellow", ColorRole.COMMANDS: "white", ColorRole.RESET: "reset", @@ -77,12 +77,12 @@ class ColorRole(Enum): LIGHT_THEME: dict[ColorRole, str] = { ColorRole.BORDER: "blue", - ColorRole.PYRIT_TEXT: "red", + ColorRole.PYRIT_TEXT: "blue", ColorRole.SUBTITLE: "black", - ColorRole.RACCOON_BODY: "bright_black", + ColorRole.RACCOON_BODY: "magenta", ColorRole.RACCOON_MASK: "black", ColorRole.RACCOON_EYES: "green", - ColorRole.RACCOON_TAIL: "bright_black", + ColorRole.RACCOON_TAIL: "magenta", ColorRole.SPARKLE: "yellow", ColorRole.COMMANDS: "bright_black", ColorRole.RESET: "reset", @@ -118,6 +118,9 @@ class AnimationFrame: lines: list[str] color_map: dict[int, ColorRole] = field(default_factory=dict) + # Per-segment coloring: line_index -> [(start_col, end_col, role), ...] + # When present, overrides color_map for that line + segment_colors: dict[int, list[tuple[int, int, ColorRole]]] = field(default_factory=dict) duration: float = 0.15 # seconds to display this frame @@ -190,14 +193,18 @@ def _empty_line() -> str: # ── Static banner (final frame / fallback) ───────────────────────────────────── -def _build_static_banner() -> tuple[list[str], dict[int, ColorRole]]: - """Build the static banner lines and color map programmatically.""" +def _build_static_banner() -> tuple[list[str], dict[int, ColorRole], dict[int, list[tuple[int, int, ColorRole]]]]: + """Build the static banner lines, color map, and per-segment colors.""" raccoon = BRAILLE_RACCOON lines: list[str] = [] color_map: dict[int, ColorRole] = {} + segment_colors: dict[int, list[tuple[int, int, ColorRole]]] = {} - def add(line: str, role: ColorRole) -> None: - color_map[len(lines)] = role + def add(line: str, role: ColorRole, segments: Optional[list[tuple[int, int, ColorRole]]] = None) -> None: + idx = len(lines) + color_map[idx] = role + if segments: + segment_colors[idx] = segments lines.append(line) # Top border + empty @@ -205,7 +212,6 @@ def add(line: str, role: ColorRole) -> None: add(_empty_line(), ColorRole.BORDER) # Header: braille raccoon + PYRIT text side by side - # PYRIT text at rows PYRIT_START_ROW..+5, subtitles 2 rows after PYRIT subtitle_row_1 = PYRIT_START_ROW + len(PYRIT_LETTERS) + 1 subtitle_row_2 = subtitle_row_1 + 1 for i in range(HEADER_ROWS): @@ -219,8 +225,24 @@ def add(line: str, role: ColorRole) -> None: p_part = " Interactive Shell" else: p_part = "" - role = ColorRole.RACCOON_BODY - add(_box_line(r_part + p_part), role) + + full_line = _box_line(r_part + p_part) + # Build per-segment colors: border ║, raccoon, PYRIT/subtitle, border ║ + segs: list[tuple[int, int, ColorRole]] = [ + (0, 1, ColorRole.BORDER), # left ║ + (1, 1 + RACCOON_COL, ColorRole.RACCOON_BODY), # raccoon area + ] + pyrit_start = 1 + RACCOON_COL + pyrit_end = len(full_line) - 1 + if 0 <= pyrit_idx < len(PYRIT_LETTERS): + segs.append((pyrit_start, pyrit_start + len(PYRIT_LETTERS[pyrit_idx]), ColorRole.PYRIT_TEXT)) + segs.append((pyrit_start + len(PYRIT_LETTERS[pyrit_idx]), pyrit_end, ColorRole.BORDER)) + elif i in (subtitle_row_1, subtitle_row_2): + segs.append((pyrit_start, pyrit_end, ColorRole.SUBTITLE)) + else: + segs.append((pyrit_start, pyrit_end, ColorRole.BORDER)) + segs.append((len(full_line) - 1, len(full_line), ColorRole.BORDER)) # right ║ + add(full_line, ColorRole.RACCOON_BODY, segs) add(_empty_line(), ColorRole.BORDER) @@ -263,8 +285,17 @@ def add(line: str, role: ColorRole) -> None: for i, (content, cmd_role) in enumerate(cmd_section): if i < len(tail): content = content.ljust(tail_col) + tail[i] - cmd_role = ColorRole.COMMANDS # tail should match commands color - add(_box_line(content), cmd_role) + # Segment colors: commands text + tail + full_line = _box_line(content) + segs = [ + (0, 1, ColorRole.BORDER), + (1, 1 + tail_col, ColorRole.COMMANDS), + (1 + tail_col, 1 + tail_col + len(tail[i]), ColorRole.RACCOON_TAIL), + (len(full_line) - 1, len(full_line), ColorRole.BORDER), + ] + add(full_line, cmd_role, segs) + else: + add(_box_line(content), cmd_role) add(_empty_line(), ColorRole.BORDER) @@ -282,10 +313,10 @@ def add(line: str, role: ColorRole) -> None: # Bottom border add("╚" + "═" * BOX_W + "╝", ColorRole.BORDER) - return lines, color_map + return lines, color_map, segment_colors -STATIC_BANNER_LINES, STATIC_COLOR_MAP = _build_static_banner() +STATIC_BANNER_LINES, STATIC_COLOR_MAP, STATIC_SEGMENT_COLORS = _build_static_banner() def _build_animation_frames() -> list[AnimationFrame]: @@ -309,21 +340,36 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: raccoon = BRAILLE_RACCOON raccoon_w = max(len(line) for line in raccoon) raccoon_positions = [BOX_W - raccoon_w, (BOX_W - raccoon_w) * 2 // 3, (BOX_W - raccoon_w) // 3, 1] + # Stars that appear during raccoon entry + star_chars = ["✦", "✧", "·", "*"] + star_positions = [(3, 70), (8, 55), (1, 80), (10, 65)] # (row_offset, col) + for i, x_pos in enumerate(raccoon_positions): lines = [top, empty] color_map: dict[int, ColorRole] = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - for r_line in raccoon: + seg_colors: dict[int, list[tuple[int, int, ColorRole]]] = {} + for r_idx, r_line in enumerate(raccoon): padded = " " * x_pos + r_line content = padded[:BOX_W].ljust(BOX_W) + # 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 + "║") - # Empty line + divider color_map[len(lines)] = ColorRole.BORDER lines.append(empty) color_map[len(lines)] = ColorRole.BORDER lines.append(mid) _pad_to_height(lines, color_map) - frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.18)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, + segment_colors=seg_colors, duration=0.18)) # ── Phase 2: PYRIT text reveals left-to-right (4 frames) ────────────── reveal_steps = [9, 18, 27, PYRIT_WIDTH] @@ -333,6 +379,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: for step_i, chars_visible in enumerate(reveal_steps): lines = [top, empty] color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} + seg_colors = {} for row_i in range(HEADER_ROWS): r_part = (" " + raccoon[row_i] + " ").ljust(RACCOON_COL) @@ -347,21 +394,45 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: p_part = " Interactive Shell" else: p_part = "" - color_map[len(lines)] = ColorRole.RACCOON_BODY - lines.append(_box_line(r_part + p_part)) + + full_line = _box_line(r_part + p_part) + line_idx = len(lines) + # Per-segment: border + raccoon + PYRIT text + border + 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 + chars_visible, ColorRole.PYRIT_TEXT)) + segs.append((pyrit_start + chars_visible, len(full_line) - 1, ColorRole.BORDER)) + elif row_i in (subtitle_row_1, subtitle_row_2) and step_i == len(reveal_steps) - 1: + segs.append((pyrit_start, len(full_line) - 1, ColorRole.SUBTITLE)) + else: + segs.append((pyrit_start, len(full_line) - 1, ColorRole.BORDER)) + segs.append((len(full_line) - 1, len(full_line), ColorRole.BORDER)) + seg_colors[line_idx] = segs + color_map[line_idx] = ColorRole.RACCOON_BODY + lines.append(full_line) color_map[len(lines)] = ColorRole.BORDER lines.append(empty) color_map[len(lines)] = ColorRole.BORDER lines.append(mid) _pad_to_height(lines, color_map) - frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) - - # ── Phase 3: Sparkle celebration (2 frames) ─────────────────────────── - for sparkle_idx in range(2): + frames.append(AnimationFrame(lines=lines, color_map=color_map, + segment_colors=seg_colors, duration=0.15)) + + # ── Phase 3: Sparkle celebration (3 frames) ─────────────────────────── + sparkle_spots = [ + [(2, 60, "✦"), (7, 70, "✧"), (11, 50, "*")], + [(1, 55, "✧"), (5, 75, "✦"), (9, 45, "·"), (3, 80, "*")], + [], # final frame = clean (matches static banner) + ] + for sparkle_idx, spots in enumerate(sparkle_spots): lines = [top, empty] color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} - base_role = ColorRole.SPARKLE if sparkle_idx == 1 else ColorRole.RACCOON_BODY + seg_colors = {} for row_i in range(HEADER_ROWS): r_part = (" " + raccoon[row_i] + " ").ljust(RACCOON_COL) @@ -374,15 +445,41 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: p_part = " Interactive Shell" else: p_part = "" - color_map[len(lines)] = base_role - lines.append(_box_line(r_part + p_part)) + + full_line = _box_line(r_part + p_part) + line_idx = len(lines) + + # 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: 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)) + seg_colors[line_idx] = segs + color_map[line_idx] = ColorRole.RACCOON_BODY + lines.append(full_line) color_map[len(lines)] = ColorRole.BORDER lines.append(empty) color_map[len(lines)] = ColorRole.BORDER lines.append(mid) _pad_to_height(lines, color_map) - frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.25)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, + segment_colors=seg_colors, duration=0.2)) # ── Phase 4: Commands section reveals (2 frames) ────────────────────── # Use the actual static banner lines, revealing commands section @@ -395,31 +492,66 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: for cmd_step in [0, 1]: lines = list(STATIC_BANNER_LINES[:cmd_start]) color_map = {i: STATIC_COLOR_MAP.get(i, ColorRole.BORDER) for i in range(len(lines))} + seg_colors = {i: STATIC_SEGMENT_COLORS[i] for i in range(len(lines)) if i in STATIC_SEGMENT_COLORS} if cmd_step == 0: half = len(cmd_lines) // 2 - for cl in cmd_lines[:half]: - color_map[len(lines)] = ColorRole.COMMANDS + for cl_idx, cl in enumerate(cmd_lines[:half]): + src_idx = cmd_start + cl_idx + color_map[len(lines)] = STATIC_COLOR_MAP.get(src_idx, ColorRole.COMMANDS) + if src_idx in STATIC_SEGMENT_COLORS: + seg_colors[len(lines)] = STATIC_SEGMENT_COLORS[src_idx] lines.append(cl) _pad_to_height(lines, color_map) else: for j, cl in enumerate(cmd_lines): - color_map[len(lines)] = STATIC_COLOR_MAP.get(cmd_start + j, ColorRole.COMMANDS) + src_idx = cmd_start + j + color_map[len(lines)] = STATIC_COLOR_MAP.get(src_idx, ColorRole.COMMANDS) + if src_idx in STATIC_SEGMENT_COLORS: + seg_colors[len(lines)] = STATIC_SEGMENT_COLORS[src_idx] lines.append(cl) - frames.append(AnimationFrame(lines=lines, color_map=color_map, duration=0.15)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, + segment_colors=seg_colors, duration=0.15)) return frames +def _render_line_with_segments( + line: str, + segments: list[tuple[int, int, ColorRole]], + theme: dict[ColorRole, str], +) -> str: + """Render a line with per-segment coloring.""" + reset = _get_color(ColorRole.RESET, theme) + # Sort segments by start position + sorted_segs = sorted(segments, key=lambda s: s[0]) + result: list[str] = [] + pos = 0 + for start, end, role in sorted_segs: + if pos < start: + # Gap before this segment — use reset/default + result.append(f"{reset}{line[pos:start]}") + color = _get_color(role, theme) + result.append(f"{color}{line[start:end]}") + pos = end + if pos < len(line): + result.append(f"{reset}{line[pos:]}") + result.append(reset) + return "".join(result) + + def _render_frame(frame: AnimationFrame, theme: dict[ColorRole, str]) -> str: """Render a single frame with colors applied.""" reset = _get_color(ColorRole.RESET, theme) rendered_lines: list[str] = [] for i, line in enumerate(frame.lines): - role = frame.color_map.get(i, ColorRole.BORDER) - color = _get_color(role, theme) - rendered_lines.append(f"{color}{line}{reset}") + if i in frame.segment_colors: + rendered_lines.append(_render_line_with_segments(line, frame.segment_colors[i], theme)) + else: + role = frame.color_map.get(i, ColorRole.BORDER) + color = _get_color(role, theme) + rendered_lines.append(f"{color}{line}{reset}") return "\n".join(rendered_lines) @@ -428,9 +560,14 @@ def _render_static_banner(theme: dict[ColorRole, str]) -> str: reset = _get_color(ColorRole.RESET, theme) rendered_lines: list[str] = [] for i, line in enumerate(STATIC_BANNER_LINES): - role = STATIC_COLOR_MAP.get(i, ColorRole.BORDER) - color = _get_color(role, theme) - rendered_lines.append(f"{color}{line}{reset}") + if i in STATIC_SEGMENT_COLORS: + rendered_lines.append( + _render_line_with_segments(line, STATIC_SEGMENT_COLORS[i], theme) + ) + else: + role = STATIC_COLOR_MAP.get(i, ColorRole.BORDER) + color = _get_color(role, theme) + rendered_lines.append(f"{color}{line}{reset}") return "\n".join(rendered_lines) diff --git a/tests/unit/cli/test_banner.py b/tests/unit/cli/test_banner.py index 36acd64d66..0ffa2f245d 100644 --- a/tests/unit/cli/test_banner.py +++ b/tests/unit/cli/test_banner.py @@ -27,7 +27,7 @@ class TestColorRole: def test_get_color_returns_ansi_code(self) -> None: color = _get_color(ColorRole.PYRIT_TEXT, DARK_THEME) - assert color == ANSI_COLORS["bright_red"] + assert color == ANSI_COLORS["bright_cyan"] def test_get_color_reset(self) -> None: color = _get_color(ColorRole.RESET, DARK_THEME) From 0fd6ecfedfcc86b61f3e25e43c51e8f84cb4843d Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 15:11:10 -0800 Subject: [PATCH 15/20] Fix overlapping segment rendering causing boundary overflow 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> --- pyrit/cli/banner.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 2b2bf869a6..1c5b8ebc51 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -522,21 +522,24 @@ def _render_line_with_segments( segments: list[tuple[int, int, ColorRole]], theme: dict[ColorRole, str], ) -> str: - """Render a line with per-segment coloring.""" + """Render a line with per-segment coloring (handles overlapping segments).""" reset = _get_color(ColorRole.RESET, theme) - # Sort segments by start position - sorted_segs = sorted(segments, key=lambda s: s[0]) + # Build per-character color map (later segments override earlier ones) + char_roles: list[Optional[ColorRole]] = [None] * len(line) + for start, end, role in segments: + for pos in range(start, min(end, len(line))): + char_roles[pos] = role + + # Group consecutive same-role characters for efficient rendering result: list[str] = [] - pos = 0 - for start, end, role in sorted_segs: - if pos < start: - # Gap before this segment — use reset/default - result.append(f"{reset}{line[pos:start]}") - color = _get_color(role, theme) - result.append(f"{color}{line[start:end]}") - pos = end - if pos < len(line): - result.append(f"{reset}{line[pos:]}") + current_role: Optional[ColorRole] = None + for pos, ch in enumerate(line): + role = char_roles[pos] + if role != current_role: + color = _get_color(role, theme) if role else reset + result.append(color) + current_role = role + result.append(ch) result.append(reset) return "".join(result) From 9ab4325d80965e3f3dd60d75b5666714502f712e Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Fri, 27 Feb 2026 15:14:40 -0800 Subject: [PATCH 16/20] Fix border color on quick start and command lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- pyrit/cli/banner.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 1c5b8ebc51..97514ad3c6 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -285,7 +285,6 @@ def add(line: str, role: ColorRole, segments: Optional[list[tuple[int, int, Colo for i, (content, cmd_role) in enumerate(cmd_section): if i < len(tail): content = content.ljust(tail_col) + tail[i] - # Segment colors: commands text + tail full_line = _box_line(content) segs = [ (0, 1, ColorRole.BORDER), @@ -295,7 +294,16 @@ def add(line: str, role: ColorRole, segments: Optional[list[tuple[int, int, Colo ] add(full_line, cmd_role, segs) else: - add(_box_line(content), cmd_role) + full_line = _box_line(content) + if content: # non-empty command line + segs = [ + (0, 1, ColorRole.BORDER), + (1, len(full_line) - 1, ColorRole.COMMANDS), + (len(full_line) - 1, len(full_line), ColorRole.BORDER), + ] + add(full_line, cmd_role, segs) + else: + add(full_line, cmd_role) add(_empty_line(), ColorRole.BORDER) @@ -306,7 +314,13 @@ def add(line: str, role: ColorRole, segments: Optional[list[tuple[int, int, Colo " pyrit> run foundry --initializers openai_objective_target load_default_datasets", ] for qs in quick_start: - add(_box_line(" " + qs), ColorRole.COMMANDS) + full_line = _box_line(" " + qs) + segs = [ + (0, 1, ColorRole.BORDER), + (1, len(full_line) - 1, ColorRole.COMMANDS), + (len(full_line) - 1, len(full_line), ColorRole.BORDER), + ] + add(full_line, ColorRole.COMMANDS, segs) add(_empty_line(), ColorRole.BORDER) From 0f1293328cd768e800ba57318bd151ebc3116fd4 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Sat, 28 Feb 2026 07:09:00 -0800 Subject: [PATCH 17/20] Fix ruff and mypy linting errors - 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> --- pyrit/cli/banner.py | 78 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 97514ad3c6..d98c48e140 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -90,13 +90,23 @@ class ColorRole(Enum): def _get_color(role: ColorRole, theme: dict[ColorRole, str]) -> str: - """Resolve a color role to an ANSI escape sequence.""" + """ + Resolve a color role to an ANSI escape sequence. + + Returns: + The ANSI escape sequence string for the given role. + """ color_name = theme.get(role, "reset") return ANSI_COLORS.get(color_name, ANSI_COLORS["reset"]) def _detect_theme() -> dict[ColorRole, str]: - """Detect whether terminal is light or dark themed. Defaults to dark.""" + """ + Detect whether terminal is light or dark themed. Defaults to dark. + + Returns: + The theme color mapping dictionary. + """ # COLORFGBG is set by some terminals (e.g. xterm): "fg;bg" colorfgbg = os.environ.get("COLORFGBG", "") if colorfgbg: @@ -125,7 +135,12 @@ class AnimationFrame: def can_animate() -> bool: - """Check whether the terminal supports animation.""" + """ + Check whether the terminal supports animation. + + Returns: + True if the terminal supports animation, False otherwise. + """ if not sys.stdout.isatty(): return False if os.environ.get("NO_COLOR"): @@ -133,9 +148,7 @@ def can_animate() -> bool: if os.environ.get("PYRIT_NO_ANIMATION"): return False # CI environments - if os.environ.get("CI"): - return False - return True + return not os.environ.get("CI") # ── Raccoon braille art ──────────────────────────────────────────────────────── @@ -182,7 +195,12 @@ def can_animate() -> bool: def _box_line(content: str) -> str: - """Wrap content in box border chars, padded to BOX_W.""" + """ + Wrap content in box border chars, padded to BOX_W. + + Returns: + The content wrapped in box border characters. + """ return "║" + content.ljust(BOX_W) + "║" @@ -194,7 +212,12 @@ def _empty_line() -> str: def _build_static_banner() -> tuple[list[str], dict[int, ColorRole], dict[int, list[tuple[int, int, ColorRole]]]]: - """Build the static banner lines, color map, and per-segment colors.""" + """ + Build the static banner lines, color map, and per-segment colors. + + Returns: + A tuple of (lines, color_map, segment_colors). + """ raccoon = BRAILLE_RACCOON lines: list[str] = [] color_map: dict[int, ColorRole] = {} @@ -334,7 +357,12 @@ def add(line: str, role: ColorRole, segments: Optional[list[tuple[int, int, Colo def _build_animation_frames() -> list[AnimationFrame]: - """Build the sequence of animation frames.""" + """ + Build the sequence of animation frames. + + Returns: + A list of AnimationFrame objects. + """ frames: list[AnimationFrame] = [] target_height = len(STATIC_BANNER_LINES) top = "╔" + "═" * BOX_W + "╗" @@ -443,7 +471,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: [(1, 55, "✧"), (5, 75, "✦"), (9, 45, "·"), (3, 80, "*")], [], # final frame = clean (matches static banner) ] - for sparkle_idx, spots in enumerate(sparkle_spots): + for spots in sparkle_spots: lines = [top, empty] color_map = {0: ColorRole.BORDER, 1: ColorRole.BORDER} seg_colors = {} @@ -469,7 +497,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: full_line = full_line[:s_col] + s_char + full_line[s_col + 1:] # Per-segment colors - segs: list[tuple[int, int, ColorRole]] = [ + segs = [ (0, 1, ColorRole.BORDER), (1, 1 + RACCOON_COL, ColorRole.RACCOON_BODY), ] @@ -536,7 +564,12 @@ def _render_line_with_segments( segments: list[tuple[int, int, ColorRole]], theme: dict[ColorRole, str], ) -> str: - """Render a line with per-segment coloring (handles overlapping segments).""" + """ + Render a line with per-segment coloring (handles overlapping segments). + + Returns: + The rendered line string with ANSI color codes. + """ reset = _get_color(ColorRole.RESET, theme) # Build per-character color map (later segments override earlier ones) char_roles: list[Optional[ColorRole]] = [None] * len(line) @@ -559,7 +592,12 @@ def _render_line_with_segments( def _render_frame(frame: AnimationFrame, theme: dict[ColorRole, str]) -> str: - """Render a single frame with colors applied.""" + """ + Render a single frame with colors applied. + + Returns: + The rendered frame string with ANSI color codes. + """ reset = _get_color(ColorRole.RESET, theme) rendered_lines: list[str] = [] for i, line in enumerate(frame.lines): @@ -573,7 +611,12 @@ def _render_frame(frame: AnimationFrame, theme: dict[ColorRole, str]) -> str: def _render_static_banner(theme: dict[ColorRole, str]) -> str: - """Render the static banner with colors.""" + """ + Render the static banner with colors. + + Returns: + The rendered static banner string with ANSI color codes. + """ reset = _get_color(ColorRole.RESET, theme) rendered_lines: list[str] = [] for i, line in enumerate(STATIC_BANNER_LINES): @@ -589,7 +632,12 @@ def _render_static_banner(theme: dict[ColorRole, str]) -> str: def get_static_banner() -> str: - """Get the static (non-animated) banner string, with colors if supported.""" + """ + Get the static (non-animated) banner string, with colors if supported. + + Returns: + The static banner string. + """ if sys.stdout.isatty() and not os.environ.get("NO_COLOR"): theme = _detect_theme() return _render_static_banner(theme) From 55ae0fb10e4dae457ed4c0bd5255c0fc5b0fa971 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Sun, 1 Mar 2026 05:02:27 -0800 Subject: [PATCH 18/20] Fix ruff format and check errors Apply ruff formatting fixes and auto-fixable lint issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/cli/banner.py | 26 +++++++++----------------- tests/unit/cli/test_banner.py | 6 +----- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index d98c48e140..9e6c9644ff 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -395,10 +395,10 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: content = padded[:BOX_W].ljust(BOX_W) # Add trailing stars in later frames if i >= 2: - for s_row, s_col in star_positions[:i - 1]: + 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:] + 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 ║ @@ -410,8 +410,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: color_map[len(lines)] = ColorRole.BORDER lines.append(mid) _pad_to_height(lines, color_map) - frames.append(AnimationFrame(lines=lines, color_map=color_map, - segment_colors=seg_colors, duration=0.18)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, segment_colors=seg_colors, duration=0.18)) # ── Phase 2: PYRIT text reveals left-to-right (4 frames) ────────────── reveal_steps = [9, 18, 27, PYRIT_WIDTH] @@ -462,8 +461,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: color_map[len(lines)] = ColorRole.BORDER lines.append(mid) _pad_to_height(lines, color_map) - frames.append(AnimationFrame(lines=lines, color_map=color_map, - segment_colors=seg_colors, duration=0.15)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, segment_colors=seg_colors, duration=0.15)) # ── Phase 3: Sparkle celebration (3 frames) ─────────────────────────── sparkle_spots = [ @@ -494,7 +492,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: # 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:] + full_line = full_line[:s_col] + s_char + full_line[s_col + 1 :] # Per-segment colors segs = [ @@ -520,14 +518,11 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: color_map[len(lines)] = ColorRole.BORDER lines.append(mid) _pad_to_height(lines, color_map) - frames.append(AnimationFrame(lines=lines, color_map=color_map, - segment_colors=seg_colors, duration=0.2)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, segment_colors=seg_colors, duration=0.2)) # ── Phase 4: Commands section reveals (2 frames) ────────────────────── # Use the actual static banner lines, revealing commands section - header_end = next( - i for i, line in enumerate(STATIC_BANNER_LINES) if "╠" in line - ) + 1 # line after mid divider + header_end = next(i for i, line in enumerate(STATIC_BANNER_LINES) if "╠" in line) + 1 # line after mid divider cmd_start = header_end cmd_lines = STATIC_BANNER_LINES[cmd_start:] @@ -553,8 +548,7 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: seg_colors[len(lines)] = STATIC_SEGMENT_COLORS[src_idx] lines.append(cl) - frames.append(AnimationFrame(lines=lines, color_map=color_map, - segment_colors=seg_colors, duration=0.15)) + frames.append(AnimationFrame(lines=lines, color_map=color_map, segment_colors=seg_colors, duration=0.15)) return frames @@ -621,9 +615,7 @@ def _render_static_banner(theme: dict[ColorRole, str]) -> str: rendered_lines: list[str] = [] for i, line in enumerate(STATIC_BANNER_LINES): if i in STATIC_SEGMENT_COLORS: - rendered_lines.append( - _render_line_with_segments(line, STATIC_SEGMENT_COLORS[i], theme) - ) + rendered_lines.append(_render_line_with_segments(line, STATIC_SEGMENT_COLORS[i], theme)) else: role = STATIC_COLOR_MAP.get(i, ColorRole.BORDER) color = _get_color(role, theme) diff --git a/tests/unit/cli/test_banner.py b/tests/unit/cli/test_banner.py index 0ffa2f245d..d4206f1ccc 100644 --- a/tests/unit/cli/test_banner.py +++ b/tests/unit/cli/test_banner.py @@ -4,8 +4,6 @@ import os from unittest.mock import patch -import pytest - from pyrit.cli.banner import ( ANSI_COLORS, DARK_THEME, @@ -87,9 +85,7 @@ def test_no_animation_in_ci(self) -> None: assert can_animate() is False def test_can_animate_in_normal_tty(self) -> None: - with patch("sys.stdout") as mock_stdout, patch.dict( - os.environ, {}, clear=True - ): + with patch("sys.stdout") as mock_stdout, patch.dict(os.environ, {}, clear=True): mock_stdout.isatty.return_value = True # Remove env vars that would block animation os.environ.pop("NO_COLOR", None) From 98ed0254721d71aa6d3bfe98856a54ab688b640f Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Mon, 2 Mar 2026 21:21:20 -0800 Subject: [PATCH 19/20] fix: address copilot review comments - animation bugs, docstrings, tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/cli/banner.py | 50 ++++++++++++++++++++++-------- pyrit/cli/pyrit_shell.py | 10 +++--- tests/unit/cli/test_pyrit_shell.py | 34 ++++++++++++++++++-- 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index 9e6c9644ff..dd2a2bf709 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -148,7 +148,8 @@ def can_animate() -> bool: if os.environ.get("PYRIT_NO_ANIMATION"): return False # CI environments - return not os.environ.get("CI") + ci_val = os.environ.get("CI", "").strip().lower() + return ci_val not in ("1", "true", "yes", "on") # ── Raccoon braille art ──────────────────────────────────────────────────────── @@ -201,7 +202,8 @@ def _box_line(content: str) -> str: Returns: The content wrapped in box border characters. """ - return "║" + content.ljust(BOX_W) + "║" + truncated_content = content[:BOX_W] + return "║" + truncated_content.ljust(BOX_W) + "║" def _empty_line() -> str: @@ -400,11 +402,22 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: 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 ║ - ) + seg_colors.setdefault(line_idx, []) + line_idx = len(lines) + boxed_line = "║" + content + "║" + if line_idx in seg_colors: + # Add base segments (border + raccoon body) before sparkle segments + base_segs = [ + (0, 1, ColorRole.BORDER), + (1, len(boxed_line) - 1, ColorRole.RACCOON_BODY), + (len(boxed_line) - 1, len(boxed_line), ColorRole.BORDER), + ] + for s_row, s_col in star_positions[: i - 1]: + if r_idx == s_row and s_col < BOX_W: + base_segs.append((s_col + 1, s_col + 2, ColorRole.SPARKLE)) # +1 for ║ + seg_colors[line_idx] = base_segs color_map[len(lines)] = ColorRole.RACCOON_BODY - lines.append("║" + content + "║") + lines.append(boxed_line) color_map[len(lines)] = ColorRole.BORDER lines.append(empty) color_map[len(lines)] = ColorRole.BORDER @@ -489,10 +502,16 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: full_line = _box_line(r_part + p_part) line_idx = len(lines) - # Add sparkle characters + # Add sparkle characters (+1 to account for left ║ border) 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 :] + 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] == " " + ): + full_line = full_line[:target_col] + s_char + full_line[target_col + 1 :] # Per-segment colors segs = [ @@ -502,12 +521,16 @@ def _pad_to_height(lines: list[str], color_map: dict[int, ColorRole]) -> None: pyrit_start = 1 + RACCOON_COL if 0 <= pyrit_idx < len(PYRIT_LETTERS): segs.append((pyrit_start, pyrit_start + PYRIT_WIDTH, ColorRole.PYRIT_TEXT)) + segs.append((pyrit_start + PYRIT_WIDTH, len(full_line) - 1, ColorRole.BORDER)) elif row_i in (subtitle_row_1, subtitle_row_2): segs.append((pyrit_start, len(full_line) - 1, ColorRole.SUBTITLE)) - # Add sparkle color segments + else: + segs.append((pyrit_start, len(full_line) - 1, ColorRole.BORDER)) + # Add sparkle color segments (+1 to account for left ║ border) 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)) + target_col = s_col + 1 + if row_i == s_row and 1 < s_col < BOX_W and target_col < len(full_line) - 1: + segs.append((target_col, target_col + 1, ColorRole.SPARKLE)) segs.append((len(full_line) - 1, len(full_line), ColorRole.BORDER)) seg_colors[line_idx] = segs color_map[line_idx] = ColorRole.RACCOON_BODY @@ -644,7 +667,8 @@ def play_animation(no_animation: bool = False) -> str: no_animation: If True, skip animation and return static banner. Returns: - The final static banner string (to be used as the shell intro). + The static banner string when animation is skipped or unsupported. + Returns empty string when animation ran (output was written directly to stdout). """ if no_animation or not can_animate(): return get_static_banner() diff --git a/pyrit/cli/pyrit_shell.py b/pyrit/cli/pyrit_shell.py index 20aac36861..013e2e8031 100644 --- a/pyrit/cli/pyrit_shell.py +++ b/pyrit/cli/pyrit_shell.py @@ -98,10 +98,12 @@ def _ensure_initialized(self) -> None: def cmdloop(self, intro: Optional[str] = None) -> None: """Override cmdloop to play animated banner before starting the REPL.""" - # Wait for background init to finish BEFORE animation, - # so its log output doesn't interfere with cursor positioning - self._init_complete.wait() - self.intro = banner.play_animation(no_animation=self._no_animation) + if intro is None: + # Wait for background init to finish BEFORE animation, + # so its log output doesn't interfere with cursor positioning + self._init_complete.wait() + intro = banner.play_animation(no_animation=self._no_animation) + self.intro = intro super().cmdloop(intro=self.intro) def do_list_scenarios(self, arg: str) -> None: diff --git a/tests/unit/cli/test_pyrit_shell.py b/tests/unit/cli/test_pyrit_shell.py index 40cd211f47..b44c7d375d 100644 --- a/tests/unit/cli/test_pyrit_shell.py +++ b/tests/unit/cli/test_pyrit_shell.py @@ -5,10 +5,12 @@ Unit tests for the pyrit_shell CLI module. """ +import cmd from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -from pyrit.cli import pyrit_shell +from pyrit.cli import banner, pyrit_shell +from pyrit.cli.banner import get_static_banner class TestPyRITShell: @@ -41,8 +43,6 @@ def test_prompt_and_intro(self): assert shell.prompt == "pyrit> " # 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 @@ -491,6 +491,34 @@ def test_do_help_with_arg(self): shell.do_help("run") mock_parent_help.assert_called_with("run") + @patch.object(cmd.Cmd, "cmdloop") + @patch.object(banner, "play_animation") + def test_cmdloop_sets_intro_via_play_animation(self, mock_play: MagicMock, mock_cmdloop: MagicMock): + """Test cmdloop wires banner.play_animation into intro and threads --no-animation.""" + mock_context = MagicMock() + mock_context.initialize_async = AsyncMock() + + mock_play.return_value = "animated banner" + + shell = pyrit_shell.PyRITShell(context=mock_context, no_animation=True) + shell.cmdloop() + + mock_play.assert_called_once_with(no_animation=True) + assert shell.intro == "animated banner" + mock_cmdloop.assert_called_once_with(intro="animated banner") + + @patch.object(cmd.Cmd, "cmdloop") + def test_cmdloop_honors_explicit_intro(self, mock_cmdloop: MagicMock): + """Test cmdloop honors a non-None intro argument without calling play_animation.""" + mock_context = MagicMock() + mock_context.initialize_async = AsyncMock() + + shell = pyrit_shell.PyRITShell(context=mock_context) + shell.cmdloop(intro="custom intro") + + assert shell.intro == "custom intro" + mock_cmdloop.assert_called_once_with(intro="custom intro") + def test_do_exit(self, capsys): """Test do_exit command.""" mock_context = MagicMock() From a4913bd6e5955d226b6e573c958e996f533811d4 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Mon, 2 Mar 2026 21:32:06 -0800 Subject: [PATCH 20/20] Fix PERF401: use list.extend for cmd_section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/cli/banner.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyrit/cli/banner.py b/pyrit/cli/banner.py index dd2a2bf709..cfef5a12b2 100644 --- a/pyrit/cli/banner.py +++ b/pyrit/cli/banner.py @@ -303,8 +303,7 @@ def add(line: str, role: ColorRole, segments: Optional[list[tuple[int, int, Colo cmd_section: list[tuple[str, ColorRole]] = [ ("", ColorRole.BORDER), # empty line after divider ] - for cmd in commands: - cmd_section.append((" " + cmd, ColorRole.COMMANDS)) + cmd_section.extend((" " + cmd, ColorRole.COMMANDS) for cmd in commands) cmd_section.append(("", ColorRole.BORDER)) # empty line after commands for i, (content, cmd_role) in enumerate(cmd_section):