diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 591a7ea..044c923 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -25,6 +25,7 @@ "plan_ready", "turn_meta", "context_summary", + "recall", ] content: str = "" meta: dict = field(default_factory=dict) @@ -238,6 +239,13 @@ if msg_type in ("heartbeat", "session_sync"): return None + if msg_type == "recall_update": + # schedule_recall lifecycle (scheduled/fired/cancelled/skipped/ + # rescheduled), pushed out-of-band by the scheduler/orchestrator. + item = ChatItem(kind="recall", meta=msg) + self.items.append(item) + return item + # Unknown event — store as status for debugging. item = ChatItem(kind="status", content=f"{msg_type}: {msg}") self.items.append(item) diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index 5ba71c3..871d812 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, todo, turn_meta, summary, filesystem +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, todo, turn_meta, summary, filesystem, recall def default_registry() -> RendererRegistry: @@ -30,6 +30,8 @@ reg.register(planning.PlanReadyRenderer()) reg.register(turn_meta.TurnMetaRenderer()) reg.register(summary.ContextSummaryRenderer()) + # recall_update lifecycle cards (scheduled/fired/cancelled/…). + reg.register(recall.RecallRenderer()) reg.register(markdown_content.MarkdownRenderer()) reg.register(diff.DiffRenderer()) reg.register(artifact.ArtifactRenderer()) diff --git a/clients/terminal/tui/renderers/recall.py b/clients/terminal/tui/renderers/recall.py new file mode 100644 index 0000000..66907b7 --- /dev/null +++ b/clients/terminal/tui/renderers/recall.py @@ -0,0 +1,78 @@ +"""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] + + +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")) + 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) + return body \ No newline at end of file diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 64ac41b..ee3e1f8 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -60,6 +60,8 @@ "messages_before": item.meta.get("messages_before"), "messages_after": item.meta.get("messages_after"), } + if kind == "recall": + return {"type": "recall_update", **item.meta} return {"type": "plain", "content": item.content} diff --git a/tests/clients/test_recall_renderer.py b/tests/clients/test_recall_renderer.py new file mode 100644 index 0000000..1231a97 --- /dev/null +++ b/tests/clients/test_recall_renderer.py @@ -0,0 +1,153 @@ +"""Tests for the ``recall_update`` renderer and its chat-model mapping.""" + +from __future__ import annotations + +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.renderers.recall import RecallRenderer, _format_trigger +from clients.terminal.tui.themes import get_active_theme, set_active_theme + + +def _render_text(renderable) -> str: + console = Console(record=True, width=100, force_terminal=True, color_system=None) + console.print(renderable) + return console.export_text() + + +def _panel(renderable) -> Panel: + return renderable + + +# ── accepts ─────────────────────────────────────────────────────────────────── + + +def test_accepts_recall_update() -> None: + set_active_theme("gnexus-dark") + renderer = RecallRenderer() + assert renderer.accepts({"type": "recall_update", "action": "scheduled"}) + assert not renderer.accepts({"type": "tool_call", "tool": "schedule_recall"}) + assert not renderer.accepts({"type": "status", "content": "x"}) + + +# ── per-action rendering ───────────────────────────────────────────────────── + + +def test_scheduled_card_title_and_color() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = RecallRenderer() + msg = { + "type": "recall_update", + "action": "scheduled", + "call_type": "once", + "trigger_at": "2026-07-12T18:45:00", + } + panel = renderer.render(msg) + assert isinstance(panel, Panel) + assert "Recall scheduled" in panel.title + assert panel.border_style == theme.info.hex + text = _render_text(panel) + assert "once" in text + assert "2026-07-12 18:45:00" in text + + +def test_fired_card_uses_accent() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = RecallRenderer() + msg = {"type": "recall_update", "action": "fired", "call_type": "immediate"} + panel = renderer.render(msg) + assert "Recall fired" in panel.title + assert panel.border_style == theme.accent.hex + + +def test_cancelled_card_uses_error() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = RecallRenderer() + msg = {"type": "recall_update", "action": "cancelled", "call_type": "once"} + panel = renderer.render(msg) + assert "Recall cancelled" in panel.title + assert panel.border_style == theme.error.hex + + +def test_rescheduled_and_skipped_colors() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = RecallRenderer() + resch = renderer.render({"type": "recall_update", "action": "rescheduled"}) + skipped = renderer.render({"type": "recall_update", "action": "skipped"}) + assert resch.border_style == theme.info.hex + assert skipped.border_style == theme.warning.hex + + +def test_unknown_action_falls_back_dim() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = RecallRenderer() + panel = renderer.render({"type": "recall_update", "action": "weird"}) + assert panel.border_style == theme.text_dim.hex + assert "weird" in panel.title + + +# ── trigger formatting ─────────────────────────────────────────────────────── + + +def test_format_trigger_trims_microseconds() -> None: + assert _format_trigger("2026-07-12T18:45:00.123456") == "2026-07-12 18:45:00" + assert _format_trigger(None) == "" + assert _format_trigger("") == "" + + +def test_body_empty_when_no_details() -> None: + set_active_theme("gnexus-dark") + renderer = RecallRenderer() + panel = renderer.render({"type": "recall_update", "action": "fired"}) + # No call_type / trigger_at → body is an empty Text; title still carries info. + assert isinstance(panel.renderable, Text) + assert panel.renderable.plain == "" + + +# ── chat-model mapping ─────────────────────────────────────────────────────── + + +def test_chat_model_recall_update_creates_recall_item() -> None: + """A live recall_update event maps to a dedicated ``recall`` ChatItem + (not the unknown-event status dump), carrying the wire fields in meta.""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + item = model.handle_ws_event( + { + "type": "recall_update", + "session_id": "s1", + "recall_id": "r1", + "call_type": "once", + "trigger_at": "2026-07-12T18:45:00", + "status": "pending", + "action": "scheduled", + } + ) + assert item is not None + assert item.kind == "recall" + assert item.meta["action"] == "scheduled" + assert item.meta["call_type"] == "once" + assert model.items[-1] is item + + +def test_chat_panel_item_msg_round_trips_recall() -> None: + """_item_msg reconstructs the recall_update wire dict from a recall item.""" + from clients.terminal.tui.chat_model import ChatItem + from clients.terminal.tui.widgets.chat_panel import _item_msg + + item = ChatItem( + kind="recall", + meta={"action": "fired", "call_type": "immediate", "status": "fired"}, + ) + msg = _item_msg(item) + assert msg["type"] == "recall_update" + assert msg["action"] == "fired" + assert msg["call_type"] == "immediate" + assert RecallRenderer().accepts(msg) \ No newline at end of file