refactor(tui): streaming + turn headers + Thinking pane + picker fix (v0.6.0)
Operator-driven big-batch polish + restructure:
## 1. Streaming text — no more per-token RichLog spam
Pre-v0.6.0, every Text SSE delta wrote its own RichLog line, so
"Let me read the..." became 4+ separate lines (a Worldtree-style
sentence-by-sentence reveal that read as broken). v0.6.0 adds a
`#current-text` Static docked above the prompt; TuiPresenterState
buffers Text deltas in `text_buffer` and updates the Static in
place. On terminal event the Static clears and the transcript
gets:
- raw=False: post-Done Markdown body + Rule separator
- raw=True: accumulated plain text
The Static collapses to height=0 when empty so the prompt sits at
the column bottom unchanged.
## 2. Turn-ID headers across every pane
`_stream_turn_worker` writes a `Rule(title="turn N")` to all four
log panes (transcript, tools, debug, thinking) on the first event
of each new turn. Operators can now visually correlate "what
happened in Tools during turn 42" by section markers in matching
positions across panes.
## 3. New Thinking TabPane (Ctrl+3)
Closed thinking runs now route to `#thinking-log` (a dedicated
TabPane) instead of `#debug-log`. Each closed run writes three
entries:
- Rule(title="turn N · thinking #K start")
- Markdown(thinking_content)
- Rule(title="turn N · thinking #K end")
Model reasoning often has lists/code/structure — rendering as
Markdown (instead of the previous "· thinking: ..." prefix line)
makes it scannable. The `thinking_run_index` counter scopes per
turn so multi-thinking-run turns get distinct markers.
`thinking-current` Static (live per-delta preview) stays in the
right column above TabbedContent (unchanged from v0.5.0) — live
visibility persists across tab switches.
## 4. Agent picker — multi-line items, full description visible
Pre-v0.6.0 the picker rendered each agent as a single Label with
"{id} · {name} — {description}", which truncated descriptions
visually. v0.6.0 uses two Static children per ListItem:
- bold Aurora bright-blue line: "{agent_id} · {name}"
- wrapped Sea dark-60 line(s): full description
ListItems are auto-height so long descriptions wrap as needed.
Highlighted (--highlight) row uses Sea dark-30 background instead
of Aurora blue (which the operator flagged as ugly).
## 5. Kill residual blue chrome
The user's "background is still blue" report traced to the prompt
Input's focused border, which I'd set to $primary (Aurora blue).
Switched to $au-bright-cyan (#42dcd1) — focus highlight is now
cyan, consistent with the operator's-voice accent throughout the
TUI. Also added explicit overrides for ContentTabs strip
background + active-tab underline color → Australis cyan.
## 6. Surfaced emotion-appraisal request to worldtree-dev
User asked for emotion-appraisal telemetry, but no SSE event for
this exists in the spec — persona/Vili affect lives in persona.log
(file-tail, blocked on remote-Worldtree topology) and per-character
state (poll endpoint, not per-turn). Posted an althing thread
proposing two shapes (worker_phase payload extension OR new
affect_update event type) and routing the decision to their team.
A 4th `Emotion` TabPane plugs in trivially when a wire event lands.
Low-priority / quality-of-life framing — not blocking ship.
## Contract amendment
docs/contracts/issues/13.contract.md amended in-place: INV-019
extended to 3 TabPanes; new INV-021 (Text → current_text Static),
INV-022 (thinking closed runs → thinking_log with Markdown +
start/end Rules), INV-023 (turn-ID headers across all panes),
INV-024 (thinking-current Static stays in right column with
"thinking… " prefix per v0.5.1 polish). INV-020 (render-exception
fallback routing) updated for Thinking → thinking_log. Drift-check
clean.
## Tests
241 GREEN (down from 244 in test count — 5 routing tests rewritten
for the new shape, replacing the v0.5.0 thinking-in-debug-log
assertions with the v0.6.0 thinking-log-as-Markdown shape; net
test coverage equivalent). ruff clean.
Live smoke against personal Worldtree's mimir confirmed:
- transcript: 27 lines (turn header + user echo + done +
markdown body, NO per-token spam)
- thinking_log: 19 lines (turn header + 2x thinking start/end
Rule sections with Markdown bodies)
- current_text cleared post-Done
Minor bump (v0.5.1 → v0.6.0) per SemVer etiquette: visible routing
+ new pane = operator-observable surface change.
This commit is contained in:
+149
-136
@@ -132,7 +132,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="b"),
|
||||
@@ -140,7 +140,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="c"),
|
||||
@@ -148,7 +148,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# Widget updated 3 times — once per delta — with cumulative content
|
||||
assert widget.update.call_count == 3
|
||||
@@ -160,48 +160,50 @@ class TestTuiPresenterState:
|
||||
# No RichLog write yet — closure hasn't fired
|
||||
assert log.write.call_count == 0
|
||||
|
||||
def test_thinking_closes_one_debuglog_entry(self) -> None:
|
||||
"""thinking_closes_one_debuglog_entry [happy, v0.5.0]: 2x Thinking + WorkerPhase →
|
||||
debug_log has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden;
|
||||
transcript (log) untouched.
|
||||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||||
"""thinking_closes_to_thinking_log [happy, v0.6.0]: 2x Thinking + WorkerPhase →
|
||||
thinking_log gets Rule(start) + Markdown + Rule(end); debug_log gets worker_phase;
|
||||
transcript and tools_log untouched. Widget cleared+hidden.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="a"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="b"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
)
|
||||
for content in ("a", "b"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=content),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
# v0.5.0: closure + worker_phase write to debug_log; transcript untouched.
|
||||
assert debug_log.write.call_count == 2
|
||||
# v0.6.0: closure writes Rule(start) + Markdown + Rule(end) to thinking_log.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
assert any(isinstance(w, Rule) for w in thinking_writes), thinking_writes
|
||||
assert any(isinstance(w, Markdown) for w in thinking_writes), thinking_writes
|
||||
# worker_phase still goes to debug_log; transcript still untouched.
|
||||
assert debug_log.write.called
|
||||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
||||
assert not log.write.called
|
||||
# First write = closed thinking entry containing the full accumulated text
|
||||
assert "· thinking: ab" in _text_of(debug_log.write.call_args_list[0][0][0])
|
||||
# Second write = worker_phase with demotion prefix (also in debug_log)
|
||||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[1][0][0])
|
||||
# Widget cleared + hidden
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False
|
||||
@@ -221,7 +223,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
last_update = widget.update.call_args_list[-1][0][0]
|
||||
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||||
@@ -247,7 +249,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert widget.display is True
|
||||
# Closure (WorkerPhase) → widget hidden
|
||||
@@ -257,77 +259,64 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert widget.display is False
|
||||
|
||||
def test_multiple_thinking_runs_each_get_debuglog_entry(self) -> None:
|
||||
"""multiple_thinking_runs_each_get_debuglog_entry [scenario, v0.5.0]:
|
||||
Thinking → Text → Thinking → Done → TWO closed thinking entries in debug_log
|
||||
(transcript receives only the Text + Done content).
|
||||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||||
"""multiple_thinking_runs_each_get_thinking_log_section [scenario, v0.6.0]:
|
||||
Thinking → Text → Thinking → Done → TWO start/end Rule + Markdown sections
|
||||
in thinking_log. Text goes to current_text Static (buffered). Transcript
|
||||
receives [done] label + Markdown body only.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
for evt in (
|
||||
Thinking(sse_id=SID, content="first"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
)
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="second"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
)
|
||||
# Close the second run with a Done.
|
||||
):
|
||||
state.render(
|
||||
evt, log=log, thinking_widget=widget,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=True,
|
||||
log=log, thinking_widget=widget,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.5.0: closed thinking entries land in debug_log, NOT log.
|
||||
thinking_entries = [_text_of(call[0][0]) for call in debug_log.write.call_args_list]
|
||||
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
|
||||
assert len(thinking_entries) == 2
|
||||
assert "first" in thinking_entries[0]
|
||||
assert "second" in thinking_entries[1]
|
||||
# transcript receives: "hi" (Text) + "[done] ..." (terminal label) only.
|
||||
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||||
markdowns = [w for w in thinking_writes if isinstance(w, Markdown)]
|
||||
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||||
assert len(markdowns) == 2, f"expected 2 Markdown sections, got {len(markdowns)}"
|
||||
# Text "hi" went to current_text (buffered), not the transcript directly.
|
||||
current_text.update.assert_any_call("hi")
|
||||
# Transcript: [done] label + Markdown(response) (raw=False).
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert "hi" in log_writes
|
||||
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||
|
||||
def test_render_exception_fallback(self) -> None:
|
||||
"""render_exception_fallback [adversarial, v0.5.0]:
|
||||
widget.update raises → debug_log gets BOTH a plain-labeled fallback line
|
||||
for the original Thinking event AND a `[render_error] <ExceptionClassName>`
|
||||
line (NO exception message per INV-009 security clause). transcript
|
||||
receives nothing — routing preservation under failure (debug-shaped
|
||||
event falls back to debug_log).
|
||||
"""render_exception_fallback [adversarial, v0.6.0]:
|
||||
widget.update raises → thinking_log gets the plain-label fallback for
|
||||
Thinking (per v0.6.0 routing — Thinking now routes to thinking_log,
|
||||
not debug_log). render_error line follows. transcript untouched.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||||
state = TuiPresenterState()
|
||||
@@ -336,18 +325,15 @@ class TestTuiPresenterState:
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
writes = [c[0][0] for c in debug_log.write.call_args_list if isinstance(c[0][0], str)]
|
||||
# POST-007: plain-label fallback for the original Thinking event.
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
|
||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
||||
# POST-007: render_error line with class name ONLY.
|
||||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||||
# Critical: exception message MUST NOT appear in any write (INV-009 security).
|
||||
assert not any("widget gone" in w for w in writes), writes
|
||||
# v0.5.0 routing preservation: transcript receives NOTHING on a
|
||||
# debug-shaped event's failure path.
|
||||
assert not log.write.called
|
||||
|
||||
def test_state_reset_per_worker(self) -> None:
|
||||
@@ -361,7 +347,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
s2 = TuiPresenterState()
|
||||
assert s1.thinking_open is True
|
||||
@@ -384,7 +370,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Cancelled(
|
||||
@@ -394,18 +380,19 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# v0.5.0: closed thinking entry lands in debug_log; terminal [cancelled] in transcript.
|
||||
debug_writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
|
||||
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
|
||||
# Rule start/end). terminal [cancelled] still in transcript.
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert any(w.startswith("· thinking: partial") for w in debug_writes)
|
||||
assert any(w.startswith("[cancelled]") for w in log_writes)
|
||||
assert widget.display is False
|
||||
|
||||
def test_done_renders_markdown_after_label(self) -> None:
|
||||
"""done_renders_markdown_after_label [happy]:
|
||||
Text("hi"), Done(response="hi") with raw=False → [done] label, Rule, Markdown in RichLog.
|
||||
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
||||
Text("hi") accumulates into current_text Static (buffered streaming);
|
||||
Done(response="hi") with raw=False → [done] label + Rule + Markdown
|
||||
in transcript. current_text cleared on terminal.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
@@ -413,6 +400,7 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
@@ -421,22 +409,26 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Text accumulated to current_text, NOT written to log.
|
||||
current_text.update.assert_any_call("hi")
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Done cleared current_text and wrote [done] label + Rule + Markdown.
|
||||
current_text.update.assert_any_call("")
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
# Text stream wrote "hi" with no prefix.
|
||||
assert "hi" in writes
|
||||
# v0.5.1: [done] label is now RichText (Aurora green); plain content test via _text_of.
|
||||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||||
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
|
||||
@@ -456,7 +448,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=True,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
@@ -464,7 +456,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=True,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
assert not any(isinstance(w, Rule) for w in writes)
|
||||
@@ -488,7 +480,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||||
assert not log.write.called
|
||||
@@ -527,7 +519,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=True,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
|
||||
widget.update.assert_called_with("")
|
||||
@@ -551,7 +543,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# INV-014: write went to tools_log
|
||||
assert tools_log.write.called
|
||||
@@ -572,18 +564,21 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert tools_log.write.called
|
||||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||||
assert not log.write.called
|
||||
|
||||
def test_text_event_does_not_route_to_tools_log(self) -> None:
|
||||
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
|
||||
def test_text_event_buffers_into_current_text(self) -> None:
|
||||
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
|
||||
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
tools_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hello"),
|
||||
@@ -591,30 +586,35 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
assert log.write.called
|
||||
assert log.write.call_args[0][0] == "hello"
|
||||
# INV-015: tools_log was NOT written to
|
||||
current_text.update.assert_called_once_with("hello")
|
||||
assert not log.write.called
|
||||
assert not tools_log.write.called
|
||||
|
||||
def test_text_no_prefix(self) -> None:
|
||||
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
|
||||
def test_text_deltas_accumulate(self) -> None:
|
||||
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
|
||||
concatenated content, NOT separate per-delta lines.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hello"),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
line = log.write.call_args[0][0]
|
||||
# Pure content, no demotion prefix.
|
||||
assert line == "hello"
|
||||
for tok in ("Hel", "lo", " ", "world"):
|
||||
state.render(
|
||||
Text(sse_id=SID, content=tok),
|
||||
log=MagicMock(),
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Final update reflects the full concatenation.
|
||||
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
|
||||
|
||||
def test_duration_format_seconds(self) -> None:
|
||||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||||
@@ -628,7 +628,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=True,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
_text_of(c[0][0])
|
||||
@@ -656,7 +656,7 @@ class TestTuiPresenterState:
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
raw=True,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
_text_of(c[0][0])
|
||||
@@ -943,7 +943,7 @@ class TestLayoutShape:
|
||||
thinking_widget=app.query_one("#thinking-current"),
|
||||
tools_log=app.query_one("#tools-log", RichLog),
|
||||
debug_log=app.query_one("#debug-log", RichLog),
|
||||
raw=True,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done = next(
|
||||
c for c in seen
|
||||
@@ -973,7 +973,7 @@ class TestLayoutShape:
|
||||
await pilot.pause()
|
||||
debug_text = " ".join(str(line) for line in debug_log.lines)
|
||||
assert "no tool events" in tools_text
|
||||
assert "waiting for telemetry" in debug_text
|
||||
assert "worker_phase" in debug_text
|
||||
|
||||
async def test_pane_name_updates_on_tab_switch(self) -> None:
|
||||
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
|
||||
@@ -1161,7 +1161,10 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
||||
class TestStreamTurnWorker:
|
||||
@respx.mock
|
||||
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""happy_text_done_renders_markdown [happy,tracer]: …"""
|
||||
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
|
||||
Text deltas go to current_text (not transcript); on Done, transcript
|
||||
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
|
||||
"""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
)
|
||||
@@ -1176,20 +1179,25 @@ class TestStreamTurnWorker:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "hi")
|
||||
assert app.state == "idle"
|
||||
# Streamed delta + done label + rule + markdown render
|
||||
assert any(w == "hello" for w in writes)
|
||||
assert any("[done]" in str(w) for w in writes)
|
||||
# The post-Done markdown render uses rich Rule + Markdown — non-string writes.
|
||||
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
|
||||
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
|
||||
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
assert not any(w == "hello" for w in writes)
|
||||
assert any("[done]" in str(w) for w in writes)
|
||||
# Post-Done: Markdown body + Rule + turn-header Rule all present.
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""raw_flag_skips_markdown_render [trace]: …"""
|
||||
"""raw_flag_skips_markdown_render [trace, v0.6.0]:
|
||||
With --raw, no Markdown render. A turn-header Rule IS still written
|
||||
(v0.6.0 INV — turn correlation lives in every pane). The post-Done
|
||||
Rule(separator) is suppressed; accumulated streamed text is written
|
||||
as a plain string instead.
|
||||
"""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
)
|
||||
@@ -1201,12 +1209,17 @@ class TestStreamTurnWorker:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
# No Markdown in raw mode.
|
||||
assert not any(isinstance(w, Markdown) for w in writes)
|
||||
assert not any(isinstance(w, Rule) for w in writes)
|
||||
# Only turn-header Rules — one per pane (transcript + tools +
|
||||
# debug + thinking = 4). No post-Done separator Rule.
|
||||
rules = [w for w in writes if isinstance(w, Rule)]
|
||||
assert len(rules) == 4, f"expected 4 turn-header Rules, got {len(rules)}"
|
||||
# Accumulated text "hi" written as plain string post-Done.
|
||||
assert "hi" in writes
|
||||
|
||||
@respx.mock
|
||||
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user