diff --git a/pyproject.toml b/pyproject.toml index 8e7f01c..0fd655a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.12.0" +version = "0.13.0" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index bdae88f..c92f0f3 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -35,9 +35,13 @@ from textual.widgets import ( from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage from ratatoskr.sessions import ( AgentInfo, + AgentNotAvailable, AgentNotFound, + AuthScopeDenied, + PersonaNotConfigured, SessionApiFailed, create_session, + get_persona_state, list_agents, ) from ratatoskr.sse_client import ( @@ -187,6 +191,79 @@ def _ts() -> str: return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}" +def _format_persona_header(snapshot: dict) -> str: + """One-line persona summary for the sticky header widget. + + Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions active`. + Built for at-a-glance scanning above the chat area — concise enough to + fit one terminal row. Full detail lives in the Persona TabPane. + """ + pad = snapshot.get("pad") or {} + emotions = snapshot.get("emotions_active") or [] + dom = snapshot.get("dominant_emotion") or "?" + pieces = [ + f"{snapshot.get('agent_id', '?')}", + f"{dom}", + f"pad({pad.get('pleasure', '?')}, {pad.get('arousal', '?')}, " + f"{pad.get('dominance', '?')})", + ] + if emotions: + pieces.append(f"{len(emotions)} emotion{'s' if len(emotions) != 1 else ''} active") + return " · ".join(pieces) + + +def _format_persona_detail(snapshot: dict) -> str: + """Multi-line persona detail for the Persona TabPane. + + Renders the full v0.28.0 snapshot shape: dominant_emotion, PAD with + baseline comparison, mood_drift deltas, active emotions list with + intensity + decay, last_updated_at footer. + """ + pad = snapshot.get("pad") or {} + baseline = snapshot.get("baseline_pad") or {} + drift = snapshot.get("mood_drift") or {} + emotions = snapshot.get("emotions_active") or [] + lines: list[str] = [] + agent = snapshot.get("agent_id", "?") + lines.append(f"Persona snapshot · {agent}") + lines.append("") + lines.append(f"Dominant emotion: {snapshot.get('dominant_emotion', '?')}") + lines.append("") + lines.append("PAD") + for axis in ("pleasure", "arousal", "dominance"): + v = pad.get(axis, "?") + b = baseline.get(axis, "?") + delta = "" + if isinstance(v, (int, float)) and isinstance(b, (int, float)): + delta = f" (Δ {v - b:+.2f})" + lines.append(f" {axis:<10} {v} baseline {b}{delta}") + lines.append("") + if drift: + lines.append("Mood drift") + for key in ("valence_delta", "arousal_delta"): + if key in drift: + v = drift[key] + lines.append(f" {key:<16} {v:+}" if isinstance(v, (int, float)) + else f" {key:<16} {v}") + lines.append("") + lines.append(f"Active emotions ({len(emotions)})") + for e in emotions: + et = e.get("type", "?") + ei = e.get("intensity", "?") + decay = e.get("decay_remaining_s") + decay_str = ( + f" decay {decay / 60:.1f}m" if isinstance(decay, (int, float)) else "" + ) + lines.append(f" {et:<20} intensity {ei}{decay_str}") + if not emotions: + lines.append(" (none)") + last = snapshot.get("last_updated_at") + if last: + lines.append("") + lines.append(f"Last updated: {last}") + return "\n".join(lines) + + def _audit_line(event: Event) -> str: """One-line wire-level audit summary for the debug pane. @@ -280,6 +357,7 @@ class TuiPresenterState: debug_log: RichLog, thinking_log: RichLog, raw: bool, + on_persona_snapshot: object = None, ) -> None: """Render one Worldtree SSE event with the TUI hierarchy + coalescing. @@ -292,6 +370,12 @@ class TuiPresenterState: - `thinking_log` (RichLog) = streaming Thinking deltas inline (coalesced on `\n`); Rule(start)/Rule(end) wrap each run. + v0.13.0: optional `on_persona_snapshot` callback receives the + snapshot dict whenever AffectUpdate(status="current") arrives. + Lets the App surface the snapshot to the persona-header + Persona + pane without the presenter needing direct widget access. Default + None — presenter falls back to audit-only routing. + Exceptions caught at the presenter boundary (INV-009 fallback). """ assert isinstance( @@ -331,13 +415,20 @@ class TuiPresenterState: if self.turn_start_ts == 0.0: self.turn_start_ts = _time.monotonic() debug_log.write(_dim(_audit_line(event))) - # v0.11.0: AffectUpdate is debug-pane-only for now (the audit - # line emitted above is the complete handling). Return early - # so the event doesn't pass through the thinking-close path - # or fall into the unknown-event ValueError branch. A full - # persona surface (Persona TabPane, sticky header line, or - # similar) is deferred to a later bump pending UX direction. + # v0.11.0 → v0.13.0: AffectUpdate gets the audit line (above) + # plus a callback to the App so the persona-header + Persona + # pane refresh from the snapshot. status="scheduled" carries + # no snapshot — the callback is skipped and the next turn's + # status="current" lands the actual update. if isinstance(event, AffectUpdate): + if event.snapshot is not None and on_persona_snapshot is not None: + try: + on_persona_snapshot(event.snapshot) + except Exception: + # Persona surface failure must not break the SSE + # stream — the audit line above already records + # the event regardless. + pass return # v0.7.1: Thinking deltas coalesce by newline before flushing. # Worldtree emits Thinking events at token granularity; per-delta @@ -796,6 +887,20 @@ class RatatoskrApp(App[int]): color: $au-dark-60; padding: 0 1; } + /* v0.13.0: sticky persona-header — one-line agent persona summary + above the main row. Empty (height: 0) when the agent has no + persona surface (PersonaNotConfigured) so the chat layout + collapses cleanly. */ + #persona-header { + dock: top; + height: 1; + color: $au-bright-80; + background: $surface; + padding: 0 1; + } + #persona-header.empty { + display: none; + } """ BINDINGS: ClassVar[list[Binding]] = [ @@ -806,6 +911,7 @@ class RatatoskrApp(App[int]): Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), Binding("ctrl+2", "focus_debug", "Debug tab", priority=False), Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False), + Binding("ctrl+4", "focus_persona", "Persona tab", priority=False), ] HINT_IDLE = "Ctrl-C twice to exit" @@ -834,6 +940,11 @@ class RatatoskrApp(App[int]): def compose(self) -> ComposeResult: yield Header() + # v0.13.0: sticky persona-header docks at the top, above main-row. + # One-line summary refreshed on each AffectUpdate(status=current). + # Starts in the .empty CSS class (height collapses to 0) until + # on_mount's get_persona_state hydration succeeds. + yield Static("", id="persona-header", classes="empty") # v0.6.0 layout: left column is content-only (transcript + streaming # text Static + prompt). Right column hosts thinking-current live # preview above TabbedContent cycling Tools / Debug / Thinking. @@ -875,6 +986,14 @@ class RatatoskrApp(App[int]): yield RichLog( id="thinking-log", wrap=True, markup=False, highlight=False ) + with TabPane("Persona", id="persona-tab"): + # v0.13.0: full persona-snapshot detail (PAD, + # mood drift, active emotions). Replaced (not + # appended) on each AffectUpdate(current) — the + # snapshot is absolute state, not incremental. + yield RichLog( + id="persona-log", wrap=True, markup=False, highlight=False + ) # INV-002 + INV-003: visible identity + hint widgets (Footer-area). # pane-name widget displays current side-pane name. yield Static("", id="identity") @@ -922,6 +1041,81 @@ class RatatoskrApp(App[int]): f"session={self.session_id[-8:]} raw={self.args.raw} " f"end_user_id={getattr(self.args, 'end_user_id', None)!r}" ) + # v0.13.0: hydrate persona surface on mount via + # GET /agents/{id}/persona_state. Spawns as a Textual worker so the + # network call doesn't block mount. Agents without a persona + # surface (PersonaNotConfigured) get a placeholder + empty header. + if self.agent_id is not None: + self.run_worker(self._hydrate_persona()) + + async def _hydrate_persona(self) -> None: + """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state. + + Failure modes are absorbed (this is best-effort observability): + - PersonaNotConfigured: pane shows placeholder, header stays empty + - AgentNotAvailable / AuthScopeDenied: error placeholder; header empty + - Network error: error placeholder; header empty + On 200: header populated, pane shows full detail, audit logged. + """ + assert self.client is not None and self.agent_id is not None + from rich.text import Text as RichText + try: + snapshot = await get_persona_state(self.client, self.agent_id) + self._update_persona_surfaces(snapshot) + self._audit( + f"persona_hydrated agent_id={self.agent_id!r} " + f"dominant_emotion={snapshot.get('dominant_emotion')!r}" + ) + except PersonaNotConfigured: + self._set_persona_placeholder( + f"(persona not configured for {self.agent_id})" + ) + self._audit(f"persona_not_configured agent_id={self.agent_id!r}") + except (AgentNotAvailable, AuthScopeDenied, SessionApiFailed, Exception) as exc: + # Best-effort — never let a persona hydration failure crash the + # TUI. Surface the error in the persona pane and audit log. + self._set_persona_placeholder( + f"(persona hydration failed: {type(exc).__name__})" + ) + self._audit( + f"persona_hydration_failed agent_id={self.agent_id!r} " + f"err={type(exc).__name__}: {exc!s:.120}" + ) + + def _update_persona_surfaces(self, snapshot: dict) -> None: + """Update sticky header + Persona pane from a fresh snapshot. + + Called on bootstrap (on_mount) and on each AffectUpdate(current). + Header gets the compact one-liner; pane gets the full detail. + """ + from rich.text import Text as RichText + try: + header = self.query_one("#persona-header", Static) + header.update(RichText(_format_persona_header(snapshot))) + header.remove_class("empty") + except Exception: + pass + try: + log = self.query_one("#persona-log", RichLog) + log.clear() + log.write(_format_persona_detail(snapshot)) + except Exception: + pass + + def _set_persona_placeholder(self, text: str) -> None: + """Render an italic-dim placeholder in the Persona pane; keep header empty. + + Used when persona hydration returns PersonaNotConfigured or fails — + the pane stays usable as documentation of *why* it's empty without + the sticky header consuming a row for nothing. + """ + from rich.text import Text as RichText + try: + log = self.query_one("#persona-log", RichLog) + log.clear() + log.write(RichText(text, style=f"{_AU_DEMOTED_FAINT} italic")) + except Exception: + pass def _write_turn_headers(self, turn_id: int) -> None: """v0.6.0: turn-ID headers across every pane for cross-pane @@ -1066,6 +1260,7 @@ class RatatoskrApp(App[int]): debug_log=debug_log, thinking_log=thinking_log, raw=self.args.raw, + on_persona_snapshot=self._update_persona_surfaces, ) if isinstance(event, (Done, Error, Cancelled)): break @@ -1151,6 +1346,11 @@ class RatatoskrApp(App[int]): self.query_one("#side-panes", TabbedContent).active = "thinking-tab" self.query_one("#pane-name", Static).update("Thinking") + def action_focus_persona(self) -> None: + """v0.13.0: Ctrl+4 activates the Persona tab. INV-016 preserves Input focus.""" + self.query_one("#side-panes", TabbedContent).active = "persona-tab" + self.query_one("#pane-name", Static).update("Persona") + def run_tui(args: ParsedArgs) -> int: """Sync entry point — delegates to the async resolve-then-run flow. diff --git a/tests/test_tui.py b/tests/test_tui.py index fc85f2f..908391a 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -934,6 +934,156 @@ class TestCancelViaSse: assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed") +class TestPersonaFormatters: + """v0.13.0 — _format_persona_header / _format_persona_detail rendering.""" + + def test_header_compact_summary(self) -> None: + """header_compact_summary: agent_id · dominant_emotion · pad(P,A,D) · N emotions.""" + from ratatoskr.tui import _format_persona_header + + snapshot = { + "agent_id": "mimir", + "dominant_emotion": "curiosity", + "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, + "emotions_active": [ + {"type": "curiosity"}, {"type": "joy"}, + ], + } + line = _format_persona_header(snapshot) + assert "mimir" in line + assert "curiosity" in line + assert "pad(0.52, 0.47, 0.5)" in line + assert "2 emotions active" in line + + def test_header_singular_emotion(self) -> None: + """header_singular_emotion: single emotion → '1 emotion active' (no 's').""" + from ratatoskr.tui import _format_persona_header + + line = _format_persona_header( + { + "agent_id": "mimir", + "dominant_emotion": "calm", + "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, + "emotions_active": [{"type": "calm"}], + } + ) + assert "1 emotion active" in line + assert "1 emotions" not in line + + def test_header_no_emotions_drops_count(self) -> None: + """header_no_emotions_drops_count: empty emotions list → no count suffix.""" + from ratatoskr.tui import _format_persona_header + + line = _format_persona_header( + { + "agent_id": "mimir", + "dominant_emotion": "?", + "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, + "emotions_active": [], + } + ) + assert "emotion" not in line # neither "1 emotion" nor "N emotions" + + def test_detail_renders_full_snapshot(self) -> None: + """detail_renders_full_snapshot: PAD axes + baseline + delta + drift + + emotions + last_updated_at all surface in the multi-line render. + """ + from ratatoskr.tui import _format_persona_detail + + snapshot = { + "agent_id": "mimir", + "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, + "dominant_emotion": "curiosity", + "emotions_active": [ + {"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7} + ], + "baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50}, + "mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07}, + "last_updated_at": "2026-05-25T22:30:18+00:00", + } + detail = _format_persona_detail(snapshot) + assert "Persona snapshot · mimir" in detail + assert "Dominant emotion: curiosity" in detail + assert "pleasure" in detail + assert "baseline 0.5" in detail + assert "+0.02" in detail or "0.02" in detail + assert "valence_delta" in detail + assert "curiosity" in detail + assert "intensity 0.6" in detail + assert "decay 3.4m" in detail + assert "2026-05-25T22:30:18+00:00" in detail + + +class TestPresenterPersonaCallback: + """v0.13.0 — presenter wires AffectUpdate snapshots into a callback.""" + + def test_current_invokes_callback_with_snapshot(self) -> None: + """current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot) + calls on_persona_snapshot(snapshot).""" + from ratatoskr.sse_client import AffectUpdate + from ratatoskr.tui import TuiPresenterState + + captured: list = [] + state = TuiPresenterState() + snapshot = {"agent_id": "mimir", "pad": {"pleasure": 0.5}} + state.render( + AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot), + transcript=MagicMock(), + tools_log=MagicMock(), + debug_log=MagicMock(), + thinking_log=MagicMock(), + raw=False, + on_persona_snapshot=captured.append, + ) + assert captured == [snapshot] + + def test_scheduled_does_not_invoke_callback(self) -> None: + """scheduled_does_not_invoke_callback: status=scheduled has no snapshot, + so the callback is skipped (would be called with None otherwise).""" + from ratatoskr.sse_client import AffectUpdate + from ratatoskr.tui import TuiPresenterState + + captured: list = [] + state = TuiPresenterState() + state.render( + AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None), + transcript=MagicMock(), + tools_log=MagicMock(), + debug_log=MagicMock(), + thinking_log=MagicMock(), + raw=False, + on_persona_snapshot=captured.append, + ) + assert captured == [] + + def test_callback_exception_swallowed(self) -> None: + """callback_exception_swallowed: a raising callback does NOT crash the + presenter — the audit line still landed (it precedes the callback). + """ + from ratatoskr.sse_client import AffectUpdate + from ratatoskr.tui import TuiPresenterState + + def boom(_snap: dict) -> None: + raise RuntimeError("widget tearing down") + + debug_log = MagicMock() + state = TuiPresenterState() + # Should NOT raise + state.render( + AffectUpdate( + sse_id=SID, status="current", turn_id=42, snapshot={"agent_id": "x"} + ), + transcript=MagicMock(), + tools_log=MagicMock(), + debug_log=debug_log, + thinking_log=MagicMock(), + raw=False, + on_persona_snapshot=boom, + ) + # Audit line still emitted (it runs before the callback) + assert debug_log.write.called + + class TestAppMount: """on_mount narrows per issue #6: only identity-widget population. @@ -1104,6 +1254,43 @@ class TestLayoutShape: await pilot.pause() assert app.query_one("#side-panes", TabbedContent).active == "debug-tab" + async def test_persona_tab_exists(self) -> None: + """persona_tab_exists [v0.13.0]: right column has Persona TabPane + + #persona-log RichLog as descendant. + """ + from textual.widgets import RichLog, TabPane + + app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") + async with app.run_test() as pilot: + await pilot.pause() + persona_tab = app.query_one("#persona-tab", TabPane) + persona_log = app.query_one("#persona-log", RichLog) + assert persona_log in persona_tab.walk_children() + + async def test_ctrl_4_activates_persona_tab(self) -> None: + """ctrl_4_activates_persona_tab [v0.13.0]: Ctrl+4 → active == 'persona-tab'.""" + from textual.widgets import TabbedContent + + app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("ctrl+4") + await pilot.pause() + assert app.query_one("#side-panes", TabbedContent).active == "persona-tab" + + async def test_persona_header_starts_empty(self) -> None: + """persona_header_starts_empty [v0.13.0]: sticky persona-header widget + exists, starts hidden (height collapsed via .empty class) until + hydration succeeds. + """ + from textual.widgets import Static + + app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") + async with app.run_test() as pilot: + await pilot.pause() + header = app.query_one("#persona-header", Static) + assert "empty" in header.classes + async def test_done_label_styled_success(self) -> None: """done_label_styled_success [v0.9.0]: [done] label mounts as Static carrying a RichText with Aurora green style. Inspect the mounted diff --git a/uv.lock b/uv.lock index 787f710..9ae874f 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "httpx" },