diff --git a/clients/terminal/tui/renderers/recall.py b/clients/terminal/tui/renderers/recall.py index 66907b7..c3f9df6 100644 --- a/clients/terminal/tui/renderers/recall.py +++ b/clients/terminal/tui/renderers/recall.py @@ -38,6 +38,23 @@ 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.""" @@ -64,6 +81,7 @@ 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() @@ -75,4 +93,9 @@ 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 \ No newline at end of file diff --git a/navi/core/events.py b/navi/core/events.py index a83f509..e31fc71 100644 --- a/navi/core/events.py +++ b/navi/core/events.py @@ -315,6 +315,7 @@ trigger_at: str | None = None status: str | None = None action: str | None = None # scheduled | cancelled | skipped | fired | rescheduled + message: str | None = None # the recall's self-instruction (additional_context_message) def to_wire(self) -> dict: return { @@ -325,6 +326,7 @@ "trigger_at": self.trigger_at, "status": self.status, "action": self.action, + "message": self.message, } diff --git a/navi/core/orchestrator.py b/navi/core/orchestrator.py index 651619c..0fa8b4c 100644 --- a/navi/core/orchestrator.py +++ b/navi/core/orchestrator.py @@ -345,6 +345,7 @@ trigger_at=next_trigger.isoformat(), status="pending", action="rescheduled", + message=recall.additional_context_message, ) return @@ -358,6 +359,7 @@ trigger_at=recall.trigger_at.isoformat(), status="fired", action="fired", + message=recall.additional_context_message, ) elif outcome == "failed": await scheduler.mark_cancelled(recall.id) @@ -368,6 +370,7 @@ trigger_at=recall.trigger_at.isoformat(), status="cancelled", action="cancelled", + message=recall.additional_context_message, ) else: # max_iterations for one-time -> mark_fired (preserving existing behaviour) @@ -379,6 +382,7 @@ trigger_at=recall.trigger_at.isoformat(), status="fired", action="fired", + message=recall.additional_context_message, ) async def run_recall( diff --git a/navi/core/scheduler.py b/navi/core/scheduler.py index 96a1923..2333bf1 100644 --- a/navi/core/scheduler.py +++ b/navi/core/scheduler.py @@ -20,6 +20,7 @@ trigger_at: str | None = None, status: str | None = None, action: str | None = None, + message: str | None = None, ) -> None: from navi.core.event_bus import get_event_bus from navi.core.events import RecallUpdate @@ -32,6 +33,7 @@ trigger_at=trigger_at, status=status, action=action, + message=message, ) ) diff --git a/navi/tools/schedule_recall.py b/navi/tools/schedule_recall.py index 21e3e56..3034622 100644 --- a/navi/tools/schedule_recall.py +++ b/navi/tools/schedule_recall.py @@ -213,6 +213,7 @@ trigger_at=recall.trigger_at.isoformat(), status="pending", action="scheduled", + message=recall.additional_context_message, ) ) except Exception: diff --git a/tests/clients/test_recall_renderer.py b/tests/clients/test_recall_renderer.py index 1231a97..4879860 100644 --- a/tests/clients/test_recall_renderer.py +++ b/tests/clients/test_recall_renderer.py @@ -6,7 +6,11 @@ from rich.panel import Panel from rich.text import Text -from clients.terminal.tui.renderers.recall import RecallRenderer, _format_trigger +from clients.terminal.tui.renderers.recall import ( + RecallRenderer, + _format_trigger, + _preview_message, +) from clients.terminal.tui.themes import get_active_theme, set_active_theme @@ -110,6 +114,59 @@ assert panel.renderable.plain == "" +# ── self-instruction (message) ──────────────────────────────────────────────── + + +def test_preview_message_single_line() -> None: + assert _preview_message("Read /tmp/build.log and report errors.") == ( + "Read /tmp/build.log and report errors." + ) + + +def test_preview_message_multiline_appends_hint() -> None: + msg = "Read /tmp/build.log.\nIf errors, tail -50 and report.\nThen mark done." + out = _preview_message(msg) + assert out is not None + assert out.startswith("Read /tmp/build.log.") + assert "(+2 lines)" in out + + +def test_preview_message_truncates_long_line() -> None: + long = "x" * 200 + out = _preview_message(long, max_len=40) + assert out is not None + assert out.endswith("…") + assert len(out) <= 40 + + +def test_preview_message_none_when_empty() -> None: + assert _preview_message(None) is None + assert _preview_message("") is None + + +def test_scheduled_card_renders_message() -> None: + set_active_theme("gnexus-dark") + renderer = RecallRenderer() + msg = { + "type": "recall_update", + "action": "scheduled", + "call_type": "once", + "trigger_at": "2026-07-12T18:45:00", + "message": "Read /tmp/build.log and report errors.", + } + text = _render_text(renderer.render(msg)) + assert "msg:" in text + assert "Read /tmp/build.log and report errors." in text + + +def test_card_without_message_has_no_msg_line() -> None: + set_active_theme("gnexus-dark") + renderer = RecallRenderer() + msg = {"type": "recall_update", "action": "fired", "call_type": "immediate"} + text = _render_text(renderer.render(msg)) + assert "msg:" not in text + + # ── chat-model mapping ─────────────────────────────────────────────────────── diff --git a/tests/unit/core/test_events.py b/tests/unit/core/test_events.py index e4fbb4b..702bbfa 100644 --- a/tests/unit/core/test_events.py +++ b/tests/unit/core/test_events.py @@ -8,6 +8,7 @@ PlanningDebugData, PlanningStatus, ProfileSwitched, + RecallUpdate, StreamEnd, StreamStopped, SubagentComplete, @@ -190,3 +191,32 @@ assert wire["session_id"] == "sess1" assert wire["tasks"] == tasks assert len(wire["tasks"]) == 2 + + +class TestRecallUpdate: + def test_to_wire_defaults_message_none(self): + ev = RecallUpdate(session_id="s1", action="scheduled") + wire = ev.to_wire() + assert wire["type"] == "recall_update" + assert wire["session_id"] == "s1" + assert wire["action"] == "scheduled" + assert wire["message"] is None + + def test_to_wire_carries_message(self): + ev = RecallUpdate( + session_id="s1", + recall_id="r1", + call_type="once", + trigger_at="2026-07-12T18:45:00", + status="pending", + action="scheduled", + message="Read /tmp/build.log and report errors.", + ) + wire = ev.to_wire() + assert wire["type"] == "recall_update" + assert wire["recall_id"] == "r1" + assert wire["call_type"] == "once" + assert wire["trigger_at"] == "2026-07-12T18:45:00" + assert wire["status"] == "pending" + assert wire["action"] == "scheduled" + assert wire["message"] == "Read /tmp/build.log and report errors."