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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user