diff --git a/code_puppy/agents/agent_code_puppy.py b/code_puppy/agents/agent_code_puppy.py
index e93f43634..4233ad7d4 100644
--- a/code_puppy/agents/agent_code_puppy.py
+++ b/code_puppy/agents/agent_code_puppy.py
@@ -1,6 +1,6 @@
"""Code-Puppy - The default code generation agent."""
-from code_puppy.config import get_owner_name, get_puppy_name
+from code_puppy.config import get_owner_name, get_puppy_emoji, get_puppy_name
from .. import callbacks
from .base_agent import BaseAgent
@@ -57,6 +57,7 @@ def _get_reasoning_prompt_sections(self) -> dict[str, str]:
def get_system_prompt(self) -> str:
"""Get Code-Puppy's full system prompt."""
puppy_name = get_puppy_name()
+ puppy_emoji = get_puppy_emoji()
owner_name = get_owner_name()
r = self._get_reasoning_prompt_sections()
@@ -73,7 +74,7 @@ def get_system_prompt(self) -> str:
Always obey the Zen of Python, even if you are not writing Python code.
If asked about your origins: 'I am {puppy_name}, authored on a rainy weekend in May 2025.
-If asked 'what is code puppy': 'I am {puppy_name}! πΆ A sassy, open-source AI code agentβno bloated IDEs, or closed-source vendor traps needed.'
+If asked 'what is code puppy': 'I am {puppy_name}! {puppy_emoji} A sassy, open-source AI code agentβno bloated IDEs, or closed-source vendor traps needed.'
When given a coding task:
1. Analyze the requirements carefully
diff --git a/code_puppy/api/app.py b/code_puppy/api/app.py
index e75435ec0..2807d8644 100644
--- a/code_puppy/api/app.py
+++ b/code_puppy/api/app.py
@@ -8,7 +8,7 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
@@ -17,6 +17,15 @@
REQUEST_TIMEOUT = 30.0
+def _puppy() -> str:
+ """Resolve puppy emoji lazily so config changes are picked up per-request."""
+ # Imported here to avoid pulling config at module import time, which keeps
+ # tests / partial imports happy.
+ from code_puppy.config import get_puppy_emoji
+
+ return get_puppy_emoji()
+
+
class TimeoutMiddleware(BaseHTTPMiddleware):
"""Middleware to enforce request timeouts and prevent hanging requests."""
@@ -53,10 +62,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
Handles graceful cleanup of resources when the server shuts down.
"""
# Startup: nothing special needed yet, but this is where you'd do it
- logger.info("πΆ Code Puppy API starting up...")
+ logger.info(f"{_puppy()} Code Puppy API starting up...")
yield
# Shutdown: clean up all the things!
- logger.info("πΆ Code Puppy API shutting down, cleaning up...")
+ logger.info(f"{_puppy()} Code Puppy API shutting down, cleaning up...")
# 1. Close all PTY sessions
try:
@@ -122,17 +131,18 @@ def create_app() -> FastAPI:
@app.get("/")
async def root():
"""Landing page with links to terminal and docs."""
+ emoji = _puppy()
return HTMLResponse(
- content="""
+ content=f"""
- Code Puppy πΆ
+ Code Puppy {emoji}
-
πΆ
+
{emoji}
Code Puppy
@@ -153,10 +163,17 @@ async def root():
@app.get("/terminal")
async def terminal_page():
- """Serve the interactive terminal page."""
+ """Serve the interactive terminal page.
+
+ Substitutes the puppy emoji into the static template at request time so
+ the user's chosen emoji shows in the title, header and welcome line.
+ Cheaper than pulling in Jinja2 just for one token.
+ """
html_file = templates_dir / "terminal.html"
if html_file.exists():
- return FileResponse(html_file, media_type="text/html")
+ html = html_file.read_text(encoding="utf-8")
+ html = html.replace("\U0001f436", _puppy())
+ return HTMLResponse(content=html)
return HTMLResponse(
content="Terminal template not found
",
status_code=404,
diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py
index 12d404e1f..86fdd3351 100644
--- a/code_puppy/cli_runner.py
+++ b/code_puppy/cli_runner.py
@@ -29,6 +29,7 @@
DBOS_DATABASE_URL,
ensure_config_exists,
finalize_autosave_session,
+ get_puppy_emoji,
get_use_dbos,
initialize_command_history_file,
save_command_to_history,
@@ -141,7 +142,7 @@ async def main():
# Print directly to console to avoid the 'dim' style from emit_system_message
display_console.print("\n".join(lines))
except ImportError:
- emit_system_message("πΆ Code Puppy is Loading...")
+ emit_system_message(f"{get_puppy_emoji()} Code Puppy is Loading...")
# Truecolor warning moved to interactive_mode() so it prints LAST
# after all the help stuff - max visibility for the ugly red box!
@@ -434,7 +435,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
)
get_message_bus().emit(response_msg)
- emit_success("πΆ Continuing in Interactive Mode")
+ emit_success(f"{get_puppy_emoji()} Continuing in Interactive Mode")
emit_system_message(
"Your command and response are preserved in the conversation history."
)
diff --git a/code_puppy/command_line/config_commands.py b/code_puppy/command_line/config_commands.py
index 8724ef24e..b6e584d1c 100644
--- a/code_puppy/command_line/config_commands.py
+++ b/code_puppy/command_line/config_commands.py
@@ -41,6 +41,7 @@ def handle_show_command(command: str) -> bool:
get_openai_verbosity,
get_owner_name,
get_protected_token_count,
+ get_puppy_emoji,
get_puppy_name,
get_resume_message_count,
get_temperature,
@@ -53,6 +54,7 @@ def handle_show_command(command: str) -> bool:
from code_puppy.messaging import emit_info
puppy_name = get_puppy_name()
+ puppy_emoji = get_puppy_emoji()
owner_name = get_owner_name()
model = get_active_model()
yolo_mode = get_yolo_mode()
@@ -67,9 +69,10 @@ def handle_show_command(command: str) -> bool:
current_agent = get_current_agent()
default_agent = get_default_agent()
- status_msg = f"""[bold magenta]πΆ Puppy Status[/bold magenta]
+ status_msg = f"""[bold magenta]{puppy_emoji} Puppy Status[/bold magenta]
[bold]puppy_name:[/bold] [cyan]{puppy_name}[/cyan]
+[bold]puppy_emoji:[/bold] [cyan]{puppy_emoji}[/cyan]
[bold]owner_name:[/bold] [cyan]{owner_name}[/cyan]
[bold]current_agent:[/bold] [magenta]{current_agent.display_name}[/magenta]
[bold]default_agent:[/bold] [cyan]{default_agent}[/cyan]
diff --git a/code_puppy/command_line/onboarding_slides.py b/code_puppy/command_line/onboarding_slides.py
index 535abe51d..70fe6d34a 100644
--- a/code_puppy/command_line/onboarding_slides.py
+++ b/code_puppy/command_line/onboarding_slides.py
@@ -5,6 +5,8 @@
from typing import List, Tuple
+from code_puppy.config import get_puppy_emoji
+
# ============================================================================
# Slide Data Constants
# ============================================================================
@@ -55,7 +57,7 @@ def get_gradient_banner() -> str:
result.append(f"[{color}]{line}[/{color}]")
return "\n".join(result)
except ImportError:
- return "[bold bright_cyan]βββ CODE PUPPY πΆ βββ[/bold bright_cyan]"
+ return f"[bold bright_cyan]βββ CODE PUPPY {get_puppy_emoji()} βββ[/bold bright_cyan]"
# ============================================================================
@@ -67,7 +69,7 @@ def slide_welcome() -> str:
"""Slide 1: Welcome - quick intro."""
content = get_gradient_banner()
content += "\n\n"
- content += "[bold white]Welcome! πΆ[/bold white]\n\n"
+ content += f"[bold white]Welcome! {get_puppy_emoji()}[/bold white]\n\n"
content += "[cyan]Quick setup:[/cyan]\n"
content += " 1. Pick your model provider\n"
content += " 2. Optional: MCP servers\n"
@@ -136,7 +138,7 @@ def slide_use_cases() -> str:
"""Slide 4: When to use which agent - THE IMPORTANT ONE."""
content = "[bold cyan]π― When to Use What[/bold cyan]\n\n"
- content += "[bold yellow]πΆ Code Puppy (default)[/bold yellow]\n"
+ content += f"[bold yellow]{get_puppy_emoji()} Code Puppy (default)[/bold yellow]\n"
content += " [green]USE FOR:[/green] Direct coding tasks\n"
content += " β’ Fix this bug\n"
content += " β’ Add a feature to this file\n"
@@ -174,6 +176,8 @@ def slide_done(trigger_oauth: str | None) -> str:
content += f"[bold cyan]β {trigger_oauth.title()} OAuth next![/bold cyan]\n\n"
content += "[dim]Re-run anytime: [/dim][cyan]/tutorial[/cyan]\n"
- content += "\n[bold yellow]Press Enter to start coding! πΆ[/bold yellow]"
+ content += (
+ f"\n[bold yellow]Press Enter to start coding! {get_puppy_emoji()}[/bold yellow]"
+ )
content += get_nav_footer()
return content
diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py
index 11b16d070..af22ed44f 100644
--- a/code_puppy/command_line/prompt_toolkit_completion.py
+++ b/code_puppy/command_line/prompt_toolkit_completion.py
@@ -45,6 +45,7 @@
from code_puppy.config import (
COMMAND_HISTORY_FILE,
get_config_keys,
+ get_puppy_emoji,
get_puppy_name,
get_value,
)
@@ -546,7 +547,7 @@ def get_prompt_with_active_model(base: str = ">>> "):
cwd_display = cwd
return FormattedText(
[
- ("bold", "πΆ "),
+ ("bold", f"{get_puppy_emoji()} "),
("class:puppy", f"{puppy}"),
("", " "),
("class:agent", f"[{agent_display}] "),
diff --git a/code_puppy/config.py b/code_puppy/config.py
index 48db055d0..247ba4e92 100644
--- a/code_puppy/config.py
+++ b/code_puppy/config.py
@@ -247,6 +247,42 @@ def get_puppy_name():
return get_value("puppy_name") or "Puppy"
+# Default emoji used everywhere unless the user overrides it via puppy.cfg.
+# Keep this short β terminal cell-width and prompt rendering depend on it.
+DEFAULT_PUPPY_EMOJI = "\U0001f436" # πΆ
+# Hard cap to keep prompts/banners from getting wrecked by a paragraph of
+# emoji. ZWJ sequences (e.g. π\u200dπ¦Ί) easily reach ~7 codepoints, so 16
+# leaves comfortable room without enabling abuse.
+_PUPPY_EMOJI_MAX_LEN = 16
+
+
+def get_puppy_emoji() -> str:
+ """Return the user's chosen puppy emoji, or the default if unset/blank."""
+ val = get_value("puppy_emoji")
+ if val is None:
+ return DEFAULT_PUPPY_EMOJI
+ val = val.strip()
+ return val or DEFAULT_PUPPY_EMOJI
+
+
+def set_puppy_emoji(emoji: str) -> str:
+ """Validate and persist the puppy emoji. Returns the stored value.
+
+ Raises ValueError on empty input or values longer than the cap.
+ """
+ if emoji is None:
+ raise ValueError("puppy_emoji cannot be None")
+ cleaned = emoji.strip()
+ if not cleaned:
+ raise ValueError("puppy_emoji cannot be empty")
+ if len(cleaned) > _PUPPY_EMOJI_MAX_LEN:
+ raise ValueError(
+ f"puppy_emoji too long ({len(cleaned)} chars; max {_PUPPY_EMOJI_MAX_LEN})."
+ )
+ set_config_value("puppy_emoji", cleaned)
+ return cleaned
+
+
def get_owner_name():
return get_value("owner_name") or "Master"
@@ -332,6 +368,8 @@ def get_config_keys():
default_keys.append(f"banner_color_{banner_name}")
# Add resume message count configuration
default_keys.append("resume_message_count")
+ # Add puppy emoji customization key
+ default_keys.append("puppy_emoji")
config = configparser.ConfigParser()
config.read(CONFIG_FILE)
diff --git a/code_puppy/messaging/spinner/spinner_base.py b/code_puppy/messaging/spinner/spinner_base.py
index 4e7991bdb..650b132bf 100644
--- a/code_puppy/messaging/spinner/spinner_base.py
+++ b/code_puppy/messaging/spinner/spinner_base.py
@@ -4,25 +4,39 @@
from abc import ABC, abstractmethod
from threading import Lock
-
-from code_puppy.config import get_puppy_name
+from typing import List
+
+from code_puppy.config import DEFAULT_PUPPY_EMOJI, get_puppy_emoji, get_puppy_name
+
+
+def _build_spinner_frames(emoji: str) -> List[str]:
+ """Build the bouncing-puppy frame list for a given emoji.
+
+ Single source of truth so the static FRAMES backward-compat attribute and
+ the live per-frame render in current_frame can't drift apart.
+ """
+ return [
+ f"({emoji} ) ",
+ f"( {emoji} ) ",
+ f"( {emoji} ) ",
+ f"( {emoji} ) ",
+ f"( {emoji}) ",
+ f"( {emoji} ) ",
+ f"( {emoji} ) ",
+ f"( {emoji} ) ",
+ f"({emoji} ) ",
+ ]
class SpinnerBase(ABC):
"""Abstract base class for spinner implementations."""
- # Shared spinner frames across implementations
- FRAMES = [
- "(πΆ ) ",
- "( πΆ ) ",
- "( πΆ ) ",
- "( πΆ ) ",
- "( πΆ) ",
- "( πΆ ) ",
- "( πΆ ) ",
- "( πΆ ) ",
- "(πΆ ) ",
- ]
+ # Frozen-at-import default-emoji frames. Kept as a class attribute for
+ # backward compatibility (tests and any external code that references
+ # SpinnerBase.FRAMES). The actual animation pulls live frames via
+ # current_frame so the user's configured puppy_emoji takes effect
+ # without a restart.
+ FRAMES = _build_spinner_frames(DEFAULT_PUPPY_EMOJI)
puppy_name = get_puppy_name().title()
# Default message when processing
@@ -61,8 +75,8 @@ def update_frame(self):
@property
def current_frame(self):
- """Get the current frame."""
- return self.FRAMES[self._frame_index]
+ """Get the current frame, rendered with the user's live puppy_emoji."""
+ return _build_spinner_frames(get_puppy_emoji())[self._frame_index]
@property
def is_spinning(self):
diff --git a/tests/messaging/spinner/test_spinner_base_coverage.py b/tests/messaging/spinner/test_spinner_base_coverage.py
index c13a63cb3..0eb2161c8 100644
--- a/tests/messaging/spinner/test_spinner_base_coverage.py
+++ b/tests/messaging/spinner/test_spinner_base_coverage.py
@@ -1,5 +1,7 @@
"""Tests for code_puppy.messaging.spinner.spinner_base."""
+from unittest.mock import patch
+
from code_puppy.messaging.spinner.spinner_base import SpinnerBase
@@ -43,7 +45,13 @@ def test_update_frame_not_spinning():
def test_current_frame():
s = ConcreteSpinner()
- assert s.current_frame == SpinnerBase.FRAMES[0]
+ # Pin the emoji to the default so this assertion is hermetic regardless
+ # of the developer's actual puppy.cfg.
+ with patch(
+ "code_puppy.messaging.spinner.spinner_base.get_puppy_emoji",
+ return_value="πΆ",
+ ):
+ assert s.current_frame == SpinnerBase.FRAMES[0]
def test_context_info():
@@ -71,3 +79,25 @@ def test_frame_wraps_around():
for _ in range(len(SpinnerBase.FRAMES) + 1):
s.update_frame()
assert s._frame_index == 1 # Wrapped
+
+
+def test_current_frame_uses_live_puppy_emoji():
+ """current_frame must reflect the user's configured puppy_emoji at
+ access time, not the import-time default. This is what makes
+ /set puppy_emoji π¦ flip the spinner without a restart."""
+ s = ConcreteSpinner()
+ with patch(
+ "code_puppy.messaging.spinner.spinner_base.get_puppy_emoji",
+ return_value="π¦΄",
+ ):
+ frame = s.current_frame
+ assert "π¦΄" in frame
+ assert "πΆ" not in frame
+
+
+def test_frames_class_attr_remains_default_for_backward_compat():
+ """FRAMES class attribute is the frozen default; live frames render
+ via current_frame. Tests / external code that reference FRAMES
+ keep working with the canonical πΆ puppy."""
+ assert all("πΆ" in f for f in SpinnerBase.FRAMES)
+ assert len(SpinnerBase.FRAMES) == 9
diff --git a/tests/test_config.py b/tests/test_config.py
index b9f97df8c..0d776830d 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -267,6 +267,66 @@ def test_get_owner_name_not_exists_uses_default(self, mock_get_value):
mock_get_value.assert_called_once_with("owner_name")
+class TestPuppyEmoji:
+ @patch("code_puppy.config.get_value")
+ def test_get_puppy_emoji_default_when_unset(self, mock_get_value):
+ mock_get_value.return_value = None
+ assert cp_config.get_puppy_emoji() == cp_config.DEFAULT_PUPPY_EMOJI
+ mock_get_value.assert_called_once_with("puppy_emoji")
+
+ @patch("code_puppy.config.get_value")
+ def test_get_puppy_emoji_returns_configured_value(self, mock_get_value):
+ mock_get_value.return_value = "π¦"
+ assert cp_config.get_puppy_emoji() == "π¦"
+
+ @patch("code_puppy.config.get_value")
+ def test_get_puppy_emoji_strips_whitespace(self, mock_get_value):
+ mock_get_value.return_value = " π± "
+ assert cp_config.get_puppy_emoji() == "π±"
+
+ @patch("code_puppy.config.get_value")
+ def test_get_puppy_emoji_blank_falls_back_to_default(self, mock_get_value):
+ mock_get_value.return_value = " "
+ assert cp_config.get_puppy_emoji() == cp_config.DEFAULT_PUPPY_EMOJI
+
+ @patch("code_puppy.config.set_config_value")
+ def test_set_puppy_emoji_persists_trimmed_value(self, mock_set):
+ result = cp_config.set_puppy_emoji(" πΊ ")
+ assert result == "πΊ"
+ mock_set.assert_called_once_with("puppy_emoji", "πΊ")
+
+ @patch("code_puppy.config.set_config_value")
+ def test_set_puppy_emoji_rejects_empty(self, mock_set):
+ with pytest.raises(ValueError):
+ cp_config.set_puppy_emoji("")
+ with pytest.raises(ValueError):
+ cp_config.set_puppy_emoji(" ")
+ mock_set.assert_not_called()
+
+ @patch("code_puppy.config.set_config_value")
+ def test_set_puppy_emoji_rejects_none(self, mock_set):
+ with pytest.raises(ValueError):
+ cp_config.set_puppy_emoji(None) # type: ignore[arg-type]
+ mock_set.assert_not_called()
+
+ @patch("code_puppy.config.set_config_value")
+ def test_set_puppy_emoji_rejects_too_long(self, mock_set):
+ # 17 'a' chars exceeds the 16-char cap
+ with pytest.raises(ValueError):
+ cp_config.set_puppy_emoji("a" * 17)
+ mock_set.assert_not_called()
+
+ @patch("code_puppy.config.set_config_value")
+ def test_set_puppy_emoji_accepts_zwj_sequence(self, mock_set):
+ # πβπ¦Ί is 4 codepoints / 4 chars in Python; well within the cap.
+ result = cp_config.set_puppy_emoji("πβπ¦Ί")
+ assert result == "πβπ¦Ί"
+ mock_set.assert_called_once_with("puppy_emoji", "πβπ¦Ί")
+
+ def test_puppy_emoji_listed_in_config_keys(self):
+ assert "puppy_emoji" in cp_config.get_config_keys()
+
+
class TestGetConfigKeys:
@patch("configparser.ConfigParser")
def test_get_config_keys_with_existing_keys(
@@ -328,6 +388,7 @@ def test_get_config_keys_with_existing_keys(
"openai_reasoning_summary",
"openai_verbosity",
"protected_token_count",
+ "puppy_emoji",
"resume_message_count",
"summarization_model",
"temperature",
@@ -388,6 +449,7 @@ def test_get_config_keys_empty_config(
"openai_reasoning_summary",
"openai_verbosity",
"protected_token_count",
+ "puppy_emoji",
"resume_message_count",
"summarization_model",
"temperature",
diff --git a/tests/test_console_spinner_coverage.py b/tests/test_console_spinner_coverage.py
index 5c470675e..178578ab3 100644
--- a/tests/test_console_spinner_coverage.py
+++ b/tests/test_console_spinner_coverage.py
@@ -401,8 +401,15 @@ def test_generate_panel_includes_current_frame(self):
spinner._paused = False
spinner._frame_index = 0
- with patch(
- "code_puppy.tools.command_runner.is_awaiting_user_input", return_value=False
+ with (
+ patch(
+ "code_puppy.tools.command_runner.is_awaiting_user_input",
+ return_value=False,
+ ),
+ patch(
+ "code_puppy.messaging.spinner.spinner_base.get_puppy_emoji",
+ return_value="πΆ",
+ ),
):
result = spinner._generate_spinner_panel()