Skip to content

feat(messaging): centralize banner formatting with opt-in timestamps#329

Open
mattnico wants to merge 1 commit intompfaffenberger:mainfrom
mattnico:feat/banner-timestamps
Open

feat(messaging): centralize banner formatting with opt-in timestamps#329
mattnico wants to merge 1 commit intompfaffenberger:mainfrom
mattnico:feat/banner-timestamps

Conversation

@mattnico
Copy link
Copy Markdown
Contributor

@mattnico mattnico commented May 8, 2026

Summary

This PR centralizes how Code Puppy renders banner tags (AGENT RESPONSE, EDIT FILE, SHELL COMMAND, THINKING, etc.) into a single helper, and uses that consolidation to introduce three opt-in formatting options. All three options default to historical behavior, so this is a no-op for users who don't enable them.

Motivation

Banner formatting was duplicated across three code paths:

  1. RichConsoleRenderer._format_banner — message-bus rendering
  2. event_stream_handler._print_thinking_banner / _print_response_banner — live-stream rendering
  3. autosave_menu.display_resumed_history — session-resume rendering (which constructed an AGENT RESPONSE banner inline, bypassing _format_banner entirely)

That meant any new banner behavior had to be implemented in three places, and the resume-history path silently diverged from the other two. While experimenting with a banner-formatting plugin I kept hitting that fan-out — every monkey-patch had to know about all three sites. This PR fixes the underlying shape: one helper, one place to change.

What's in the PR

New module: code_puppy/messaging/banner.py

Single source of truth for banner markup:

def format_banner(banner_name: str, text: str, *, when: datetime | None = None) -> str

Builds the same Rich markup string the codebase has always produced, but allows opt-in additions controlled by config:

  • A dim [HH:MM:SS] timestamp annotation on the same line, just outside the colored banner block
  • A trailing newline so the banner sits alone on its line (matches what AGENT RESPONSE already did)

Three new config keys (all default-off)

Key Type Default Effect when enabled
banner_timestamps_enabled bool false Append [HH:MM:SS] after every banner tag
banner_timestamp_format str %H:%M:%S strftime format for the annotation
banner_newline_after_tag bool false Append \n after every banner tag

set_banner_timestamp_format() validates inputs by checking that the format produces different output for two distinct datetimes — Python's strftime is permissive and silently passes unknown directives through as literals (e.g. %Q-bogus becomes Q-bogus), so this catches typos that would otherwise produce a banner displaying garbage forever.

Refactored callers (now ~3-line delegations)

  • RichConsoleRenderer._format_banner → calls format_banner(banner_name, text)
  • event_stream_handler._print_thinking_banner / _print_response_banner → call format_banner("thinking", "THINKING") / format_banner("agent_response", "AGENT RESPONSE")
  • autosave_menu.display_resumed_history → calls format_banner("agent_response", "AGENT RESPONSE", when=msg.timestamp)

Bonus fix: resumed sessions show original timestamps

pydantic-ai already stores msg.timestamp (UTC) on every message. When banner_timestamps_enabled=true, resumed-session banners now use that stored timestamp (converted to local time) instead of datetime.now(). So reloaded banners show when the message actually happened, not when you reloaded the session.

This required no new metadata, no sidecar storage, no content hashing — the data was already there.

Defaults & backward compatibility

Every new option defaults to behavior that matches main exactly:

>>> format_banner("agent_response", "AGENT RESPONSE")
'[bold white on medium_purple4] AGENT RESPONSE [/bold white on medium_purple4]'

Identical byte-for-byte to the previous markup. Existing users see zero change unless they explicitly opt in via /set banner_timestamps_enabled=true (or the other keys).

Tests

  • New: tests/messaging/test_banner.py — 16 tests covering the strftime validator, timestamp-suffix helper (disabled / live / naive datetime / aware datetime / fallback), and format_banner (default behavior, timestamp-only, newline-only, both combined).
  • Updated: tests/test_rich_renderer.py, tests/agents/test_event_stream_handler.py, tests/test_config.py — re-pointed mocks to code_puppy.config.get_banner_color (since _format_banner now delegates rather than calling its own _get_banner_color hook), and added the three new keys to the expected sorted-key list.

Full suite results on this branch: 9399 passed, 88 skipped, 1 xpassed, 4 failed. The 4 failures (test_run_pending_credentials_success, test_run_prompt_with_attachments_uses_spinner, TestDefaultAgent::test_default, test_opus_46_reverse_name_also_works) all reproduce on a clean checkout of origin/main and are unrelated to this change.

Out of scope

  • Adding EDIT FILE / SHELL COMMAND banners to display_resumed_history (it currently shows tool calls/returns as collapsed dim text). Easy follow-up but it's a separable UX decision.
  • A slash-command UI for toggling these options. Today they go through the existing /set command, which already discovers them via get_config_keys().

Try it

/set banner_timestamps_enabled=true
/set banner_timestamp_format=%H:%M:%S
/set banner_newline_after_tag=true

Then trigger any tool / response, or /resume a previous session, to see the new formatting.

Adds code_puppy.messaging.banner as the single source of truth for how
banner tags are turned into Rich markup. The three existing banner-emitting
code paths now delegate to it:

  * RichConsoleRenderer._format_banner (message-bus path)
  * event_stream_handler._print_thinking_banner / _print_response_banner
    (live-stream path)
  * autosave_menu.display_resumed_history (session-resume path)

Previously each path constructed banner markup itself, so adding any new
behavior (timestamps, spacing, etc.) meant patching three places. The
resume-history path in particular hardcoded its own AGENT RESPONSE banner
that bypassed _format_banner entirely.

New opt-in config keys (all default to historical behavior, so existing
users see no change):

  * banner_timestamps_enabled    (bool, default False)
        Append a dim [HH:MM:SS] annotation after every banner tag.
  * banner_timestamp_format      (str, default '%H:%M:%S')
        strftime format for the annotation. Validated on set: any format
        that produces identical output for two distinct datetimes is
        rejected (catches typos like '%Q-bogus' that Python silently
        passes through as literals).
  * banner_newline_after_tag     (bool, default False)
        Append a newline after every banner tag so each banner sits alone
        on its line and following content drops to the next line. Matches
        the historical AGENT RESPONSE convention for every banner.

Resumed sessions now display each AGENT RESPONSE banner with the *original*
timestamp of the message (pulled from msg.timestamp, which pydantic-ai
already stores in UTC on every message). Aware datetimes are converted to
the user's local time before strftime, so reloaded sessions show 'when
this actually happened' rather than 'when I reloaded'.

Tests:
  * tests/messaging/test_banner.py: 16 new tests covering the strftime
    validator, timestamp suffix (disabled / now / naive / aware), and
    format_banner (default behavior, timestamp, newline, both).
  * tests/test_rich_renderer.py: re-target the patch site (mock now lives
    on code_puppy.config.get_banner_color, since _format_banner delegates).
  * tests/agents/test_event_stream_handler.py: re-target patches similarly
    (13 occurrences).
  * tests/test_config.py: register the three new keys in the expected
    sorted-key list.
@mpfaffenberger
Copy link
Copy Markdown
Owner

Would love to see some screenshots of the new setup

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants