"""Renderer for ``recall_update`` events — the ``schedule_recall`` lifecycle.
The scheduler and orchestrator emit ``RecallUpdate`` (wire ``type =
"recall_update"``) whenever a recall is scheduled, fired, cancelled, skipped,
or rescheduled. Without this renderer those events hit the unknown-event
catch-all and render as a raw dict dump; this turns each lifecycle change into
a compact, color-coded card.
The self-instruction (``additional_context_message``) is not carried on the
wire — it stays visible in the ``schedule_recall`` tool-call card just above.
"""
from __future__ import annotations
from rich.box import ROUNDED
from rich.console import RenderableType
from rich.panel import Panel
from rich.text import Text
from clients.terminal.tui.themes import get_active_theme
from .base import ContentRenderer
# action → (icon, title, theme color attribute)
_ACTIONS: dict[str, tuple[str, str, str]] = {
"scheduled": ("⏰", "Recall scheduled", "info"),
"fired": ("▶", "Recall fired · resuming", "accent"),
"cancelled": ("✕", "Recall cancelled", "error"),
"skipped": ("↻", "Recall skipped", "warning"),
"rescheduled": ("↻", "Recall rescheduled", "info"),
}
def _format_trigger(trigger_at: str | None) -> str:
"""Trim an ISO timestamp to a readable ``YYYY-MM-DD HH:MM:SS`` form."""
if not trigger_at:
return ""
return trigger_at.replace("T", " ").split(".")[0]
def _preview_message(message: str | None, max_len: int = 80) -> str | None:
"""First line of the self-instruction plus a ``(+N lines)`` hint, capped.
Returns ``None`` when there is nothing to show.
"""
if not message:
return None
lines = message.splitlines()
first = lines[0].strip()
if len(first) > max_len:
first = first[: max_len - 1].rstrip() + "…"
more = len(lines) - 1
if more > 0:
return f"{first} …(+{more} lines)"
return first
class RecallRenderer(ContentRenderer):
"""Render a ``recall_update`` lifecycle event as a compact card."""
def accepts(self, msg: dict) -> bool:
return msg.get("type") == "recall_update"
def render(self, msg: dict) -> RenderableType:
theme = get_active_theme()
action = msg.get("action") or "?"
icon, title, color_attr = _ACTIONS.get(
action, ("•", f"Recall · {action}", "text_dim")
)
color = getattr(theme, color_attr, theme.text_dim)
panel = Panel(
self._body(msg, theme),
title=f"{icon} {title}",
title_align="left",
border_style=color.hex,
box=ROUNDED,
)
return panel
def _body(self, msg: dict, theme) -> RenderableType:
call_type = msg.get("call_type") or ""
trigger = _format_trigger(msg.get("trigger_at"))
message = _preview_message(msg.get("message"))
dim = theme.text_dim.hex
body = Text()
if call_type:
body.append("type: ", style=dim)
body.append(call_type, style=theme.text.hex)
if trigger:
if body.plain:
body.append("\n")
body.append("trigger: ", style=dim)
body.append(trigger, style=theme.text.hex)
if message:
if body.plain:
body.append("\n")
body.append("msg: ", style=dim)
body.append(message, style=theme.text.hex)
return body