feat(messaging): centralize banner formatting with opt-in timestamps#329
feat(messaging): centralize banner formatting with opt-in timestamps#329mattnico wants to merge 1 commit into
Conversation
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.
|
Would love to see some screenshots of the new setup |
mpfaffenberger
left a comment
There was a problem hiding this comment.
🐶 Code Puppy Review — PR #329
Solid PR — the centralization of banner formatting is a genuine DRY win and the backward compat story is airtight. A few things to address:
- THINKING banner +
banner_newline_after_tagbug — the trailing newline splits the banner from its ⚡ (see inline) - Dead code —
_get_banner_coloronRichConsoleRendereris now orphaned. No production code calls it anymore. It's still monkey-patched by tests attests/test_rich_renderer.pylines 91, 124, 154, 185, 317, 441 andtests/messaging/test_rich_renderer.pyline 913. Should either remove it and update those tests to patchcode_puppy.config.get_banner_color(like you did fortest_format_banner_and_level_prefix), or document why it's being kept. _coerce_boolshould replace the 15+ copy-pasted bool coercions in config.py (see inline)- Minor nits:
OptionalvsX | None, missing integration test for THINKING+newline
Not requesting changes since the newline bug only fires when someone opts in. 🐾
| Text.from_markup( | ||
| f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]\u26a1 " | ||
| ), | ||
| Text.from_markup(f"{banner_markup} [dim]\u26a1 "), |
There was a problem hiding this comment.
When banner_newline_after_tag=True, format_banner() appends \n to the markup string. Then this line constructs:
Text.from_markup(f"{banner_markup} [dim]⚡ ")The \n sits between the banner tag and the lightning bolt, so the ⚡ ends up on the next line, separated from the banner. That's almost certainly not what the user wants — the newline is meant to push the content below to a new line, not to split the banner from its own inline decoration.
Suggested fix: Either:
- Strip the trailing
\nhere since you're already doingend=""(the THINKING banner doesn't need the newline because it appends streaming content on the same line), or - Add a
newline: bool = Trueparam toformat_bannerso callers can opt out when they'll add more inline content after the banner.
| DEFAULT_BANNER_TIMESTAMP_FORMAT = "%H:%M:%S" | ||
|
|
||
|
|
||
| def _coerce_bool(raw: Any, default: bool) -> bool: |
There was a problem hiding this comment.
Nice helper! But this is the 16th place in this file that does the str(val).lower() in ("1", "true", "yes", "on") dance. The other 15+ copy-pastes are at lines 75, 106, 121, 160, 253, 1110, 1142, 1160, 1247, 1384, 1806, 1831, 1942 (and more).
Would be a great follow-up PR to refactor those to use _coerce_bool. Not blocking for this PR, but since you've already written the canonical version, it'd be a shame not to DRY up the rest of the file.
| _VALIDATION_DT_B = datetime(2099, 11, 22, 21, 33, 44) | ||
|
|
||
|
|
||
| def is_valid_strftime_format(fmt: str) -> bool: |
There was a problem hiding this comment.
💡 This two-reference-datetime approach to validate strftime formats is genuinely clever. Python's strftime silently passing unknown directives through as literals is a real footgun, and this catches it without needing to parse the format string yourself. The %% test case in the parametrized tests is a nice touch too.
One minor note: _VALIDATION_DT_A and _VALIDATION_DT_B are module-level constants but their names don't make the purpose obvious to someone who stumbles on them. A brief comment like # Used by is_valid_strftime_format to detect non-functional directives would help. Not blocking.
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime | ||
| from typing import Optional |
There was a problem hiding this comment.
Nit: Since you already have from __future__ import annotations, you could use datetime | None instead of Optional[datetime] throughout this file. It's the modern Python 3.10+ style and avoids the typing import entirely. Not blocking — just a consistency nit.
| """ | ||
| # Local import to avoid an import cycle (config imports nothing from us | ||
| # but other modules may transitively). | ||
| from code_puppy.config import ( |
There was a problem hiding this comment.
This local import to break the circular dependency is the right pattern. Just noting: if this module grows, consider whether a thin config_protocol module (that just defines the getter interfaces) might be cleaner. But for 148 lines, local imports are the pragmatic choice. 👍
| f"banner_timestamp_format {fmt!r} contains no working " | ||
| "strftime directives (e.g. %Y, %m, %d, %H, %M, %S)." | ||
| ) | ||
| set_config_value("banner_timestamp_format", fmt.replace("%", "%%")) |
There was a problem hiding this comment.
The %% escaping for configparser's BasicInterpolation is correct here, and get_banner_timestamp_format reads through get_value() which uses ConfigParser that undoes the escaping on read. Round-trip is ✅.
Just a heads-up: if anyone ever changes get_value() to use RawConfigParser (no interpolation), the %% would come back as %% instead of %, and the format string would break. Worth a brief comment in get_banner_timestamp_format noting that it relies on BasicInterpolation to un-escape the %%.
| # stores this on every message in UTC; the formatter converts to | ||
| # local time. If the timestamp is missing or banner timestamps | ||
| # are disabled, the formatter simply omits the annotation. | ||
| msg_timestamp = getattr(msg, "timestamp", None) |
There was a problem hiding this comment.
Using getattr(msg, "timestamp", None) to fish the stored timestamp out of pydantic-ai messages is a nice touch — and the fact that it gives reloaded sessions original timestamps instead of datetime.now() is a genuine UX improvement that requires zero new storage. Well done.
One thought: if msg.timestamp is ever None (e.g., an older session saved before timestamps were added), the formatter falls back to datetime.now(), which is fine for live rendering but slightly misleading for resumed history (it'd show the reload time, not the original time). Maybe a dim annotation like [original time unknown] would be more honest? Low priority.
| @@ -0,0 +1,172 @@ | |||
| """Tests for the central banner formatter.""" | |||
|
|
|||
| from __future__ import annotations | |||
There was a problem hiding this comment.
Solid test coverage for the new module — 16 tests covering the validator, suffix helper (disabled/enabled/naive/aware/fallback), and full format_banner (default, timestamp-only, newline-only, both combined).
Gap: No test for the THINKING banner + banner_newline_after_tag=True interaction (the event_stream_handler path where the trailing newline would split the banner from the ⚡). This is where the real bug lives, and since the unit test for format_banner alone can't catch it, an integration-style test on the event stream handler would be valuable. See my inline comment on event_stream_handler.py:145.
| return f" [{ts}]" | ||
|
|
||
|
|
||
| def format_banner( |
There was a problem hiding this comment.
The format_banner signature is clean and the docstring is excellent. One thing that would make it even more future-proof: consider adding a **kwargs that gets passed through, so plugin authors can extend banner rendering without having to monkey-patch this function. But that's YAGNI until someone actually needs it — just planting the seed. 🌱



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:
RichConsoleRenderer._format_banner— message-bus renderingevent_stream_handler._print_thinking_banner/_print_response_banner— live-stream renderingautosave_menu.display_resumed_history— session-resume rendering (which constructed anAGENT RESPONSEbanner inline, bypassing_format_bannerentirely)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.pySingle source of truth for banner markup:
Builds the same Rich markup string the codebase has always produced, but allows opt-in additions controlled by config:
[HH:MM:SS]timestamp annotation on the same line, just outside the colored banner blockAGENT RESPONSEalready did)Three new config keys (all default-off)
banner_timestamps_enabledfalse[HH:MM:SS]after every banner tagbanner_timestamp_format%H:%M:%Sbanner_newline_after_tagfalse\nafter every banner tagset_banner_timestamp_format()validates inputs by checking that the format produces different output for two distinct datetimes — Python'sstrftimeis permissive and silently passes unknown directives through as literals (e.g.%Q-bogusbecomesQ-bogus), so this catches typos that would otherwise produce a banner displaying garbage forever.Refactored callers (now ~3-line delegations)
RichConsoleRenderer._format_banner→ callsformat_banner(banner_name, text)event_stream_handler._print_thinking_banner/_print_response_banner→ callformat_banner("thinking", "THINKING")/format_banner("agent_response", "AGENT RESPONSE")autosave_menu.display_resumed_history→ callsformat_banner("agent_response", "AGENT RESPONSE", when=msg.timestamp)Bonus fix: resumed sessions show original timestamps
pydantic-aialready storesmsg.timestamp(UTC) on every message. Whenbanner_timestamps_enabled=true, resumed-session banners now use that stored timestamp (converted to local time) instead ofdatetime.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
mainexactly: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
tests/messaging/test_banner.py— 16 tests covering the strftime validator, timestamp-suffix helper (disabled / live / naive datetime / aware datetime / fallback), andformat_banner(default behavior, timestamp-only, newline-only, both combined).tests/test_rich_renderer.py,tests/agents/test_event_stream_handler.py,tests/test_config.py— re-pointed mocks tocode_puppy.config.get_banner_color(since_format_bannernow delegates rather than calling its own_get_banner_colorhook), 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 oforigin/mainand are unrelated to this change.Out of scope
EDIT FILE/SHELL COMMANDbanners todisplay_resumed_history(it currently shows tool calls/returns as collapsed dim text). Easy follow-up but it's a separable UX decision./setcommand, which already discovers them viaget_config_keys().Try it
Then trigger any tool / response, or
/resumea previous session, to see the new formatting.