feat(tui): persona surface — sticky header + TabPane (v0.13.0)

Step 3 of the Worldtree #204 integration: visible persona-state UX.
Pairs with v0.11.0's AffectUpdate SSE event + v0.12.0's
get_persona_state HTTP client — together those gave the data; this
bump surfaces it.

Two surfaces (Option C: both):

Sticky persona-header (top of screen, dock=top, height=1):
- Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions
  active` — concise enough for at-a-glance scan above the chat
- Starts hidden via `.empty` CSS class; height collapses to 0 when
  the agent has no persona surface
- Refreshes on AffectUpdate(status="current") snapshots

Persona TabPane (Ctrl+4):
- Full snapshot detail: dominant emotion, PAD axes with baseline +
  delta, mood drift, active emotions with intensity + decay
  (minutes-rounded), last_updated_at footer
- Replaced (not appended) on each new snapshot — snapshots are
  absolute state, not incremental

Lifecycle:
- on_mount spawns a Textual worker that calls get_persona_state to
  hydrate header + pane before turn 1
- PersonaNotConfigured (domari, muninn, Tier 3) → pane carries an
  italic placeholder, header stays empty
- AgentNotAvailable / AuthScopeDenied / network error → italic
  failure placeholder; audit-logged; never crashes
- Presenter's render() takes an optional `on_persona_snapshot`
  callback so AffectUpdate(current) refreshes both surfaces during
  a live turn (no widget coupling — App owns the callback)

Tests: 10 new (4 formatters, 3 presenter callback, 4 layout/binding/
hydration). Full suite: 313 passing.
This commit is contained in:
vh
2026-05-25 19:13:47 -07:00
parent d516537b08
commit 44138590ad
4 changed files with 395 additions and 8 deletions
+187
View File
@@ -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