Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions code_puppy/agents/agent_code_puppy.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down
33 changes: 25 additions & 8 deletions code_puppy/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
<!DOCTYPE html>
<html>
<head>
<title>Code Puppy 🐢</title>
<title>Code Puppy {emoji}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-white min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl mb-4">🐢</h1>
<h1 class="text-6xl mb-4">{emoji}</h1>
<h2 class="text-3xl font-bold mb-8">Code Puppy</h2>
<div class="space-x-4">
<a href="/terminal" class="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg text-lg font-semibold">
Expand All @@ -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="<h1>Terminal template not found</h1>",
status_code=404,
Expand Down
5 changes: 3 additions & 2 deletions code_puppy/cli_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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."
)
Expand Down
5 changes: 4 additions & 1 deletion code_puppy/command_line/config_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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]
Expand Down
12 changes: 8 additions & 4 deletions code_puppy/command_line/onboarding_slides.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from typing import List, Tuple

from code_puppy.config import get_puppy_emoji

# ============================================================================
# Slide Data Constants
# ============================================================================
Expand Down Expand Up @@ -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]"


# ============================================================================
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion code_puppy/command_line/prompt_toolkit_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from code_puppy.config import (
COMMAND_HISTORY_FILE,
get_config_keys,
get_puppy_emoji,
get_puppy_name,
get_value,
)
Expand Down Expand Up @@ -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}] "),
Expand Down
38 changes: 38 additions & 0 deletions code_puppy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
46 changes: 30 additions & 16 deletions code_puppy/messaging/spinner/spinner_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
32 changes: 31 additions & 1 deletion tests/messaging/spinner/test_spinner_base_coverage.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Loading
Loading