feat(tui): live Markdown rendering during text streaming (v0.9.0)

Replaces v0.8.2's drop-Markdown patch with proper in-place Markdown
rendering. The transcript becomes a VerticalScroll container; each
turn's response body lives as a single Static widget whose content
is updated as Text deltas arrive — Markdown is re-rendered in place
rather than re-printed on Done. Eliminates the v0.8.x double-print
without sacrificing rich formatting.

- transcript: RichLog → VerticalScroll (#transcript-scroll)
- Text deltas: mount Static(Markdown(buffer)) on first delta;
  Static.update(Markdown(buffer)) on subsequent deltas
- --raw mode: bypass Markdown, mount Static(plain_str) for the same
  in-place update semantics
- Terminal events (Done/Error/Cancelled) mount styled label Statics
- _cancel_via_sse: write → mount Static on the new container
- _write_turn_headers: transcript gets a styled RichText Static
  ("── turn N ──"); other panes still receive Rule renderables
- Test suite reshape: bulk rename `log` → `transcript` for the
  presenter contract, `_mounted_renderables` helper extracts
  Static.content for assertion, `_spy_writes` captures both
  RichLog.write and VerticalScroll.mount
This commit is contained in:
vh
2026-05-24 22:18:45 -07:00
parent 489cfee1f0
commit 139771c8d8
4 changed files with 431 additions and 238 deletions
+231 -148
View File
@@ -61,20 +61,43 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
def _spy_writes(monkeypatch) -> list:
"""Patch RichLog.write to record every arg into a list (returned).
"""Patch RichLog.write AND VerticalScroll.mount to record every renderable
or mounted-widget content into a single list (returned).
Accepts *args/**kwargs so Textual's internal deferred-render path
(which calls write positionally with width/expand/shrink/scroll_end)
still works after a write-during-mount + Resize sequence.
v0.9.0: transcript content is mounted into a VerticalScroll, not written
to a RichLog. The spy captures both shapes — for each mounted Static, the
Static's `renderable` (Markdown / RichText / str) lands in the list,
indistinguishably from RichLog.write entries. Integration tests assert
on substrings or types in `writes` so the merged shape is the right
abstraction.
Accepts *args/**kwargs so Textual's internal deferred-render paths still
work after a write-during-mount + Resize sequence.
"""
from textual.containers import VerticalScroll
from textual.widgets import Static
writes: list = []
original = RichLog.write
def spy(self, content, *args, **kw):
original_write = RichLog.write
def spy_write(self, content, *args, **kw):
writes.append(content)
return original(self, content, *args, **kw)
return original_write(self, content, *args, **kw)
monkeypatch.setattr(RichLog, "write", spy)
monkeypatch.setattr(RichLog, "write", spy_write)
original_mount = VerticalScroll.mount
def spy_mount(self, *children, **kw):
for child in children:
if isinstance(child, Static):
writes.append(child.content)
else:
writes.append(child)
return original_mount(self, *children, **kw)
monkeypatch.setattr(VerticalScroll, "mount", spy_mount)
return writes
@@ -126,13 +149,13 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
for chunk in ("Let", " me", " think"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=thinking_log,
@@ -143,7 +166,7 @@ class TestTuiPresenterState:
assert len(writes) == 1
assert isinstance(writes[0], Rule)
assert state.thinking_chunk_buffer == "Let me think"
assert log.write.call_count == 0
assert transcript.mount.call_count == 0
def test_thinking_flushes_on_newline(self) -> None:
"""thinking_flushes_on_newline [happy, v0.7.1]:
@@ -156,7 +179,7 @@ class TestTuiPresenterState:
for chunk in ("Hello", " world", "\n"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=MagicMock(),
transcript=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=thinking_log,
@@ -178,14 +201,14 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
for content in ("a", "b"):
state.render(
Thinking(sse_id=SID, content=content),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=thinking_log,
@@ -193,7 +216,7 @@ class TestTuiPresenterState:
)
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=thinking_log,
@@ -207,7 +230,7 @@ class TestTuiPresenterState:
assert isinstance(thinking_writes[2], Rule)
# worker_phase still goes to debug_log; transcript untouched.
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
assert not log.write.called
assert not transcript.mount.called
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
@@ -224,7 +247,7 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
for evt in (
@@ -233,13 +256,13 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="second"),
):
state.render(
evt, log=log,
evt, transcript=transcript,
tools_log=MagicMock(), debug_log=MagicMock(),
thinking_log=thinking_log, raw=False,
)
state.render(
_make_tui_done(),
log=log,
transcript=transcript,
tools_log=MagicMock(), debug_log=MagicMock(),
thinking_log=thinking_log, raw=False,
)
@@ -251,9 +274,9 @@ class TestTuiPresenterState:
assert "first" in delta_strs
assert "second" in delta_strs
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
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))
transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)]
assert "hi" in transcript_renderables
assert any(w.startswith("[done]") for w in transcript_renderables if isinstance(w, str))
def test_render_exception_fallback(self) -> None:
"""render_exception_fallback [adversarial, v0.6.5]:
@@ -263,7 +286,7 @@ class TestTuiPresenterState:
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
thinking_log = MagicMock()
# First call (Rule write) raises; subsequent calls succeed for fallback.
thinking_log.write.side_effect = [
@@ -274,7 +297,7 @@ class TestTuiPresenterState:
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=thinking_log,
@@ -284,7 +307,7 @@ class TestTuiPresenterState:
assert any(w.startswith("[thinking]") for w in writes), writes
assert any(w == "[render_error] AttributeError" for w in writes), writes
assert not any("rule write failed" in w for w in writes), writes
assert not log.write.called
assert not transcript.mount.called
def test_state_reset_per_worker(self) -> None:
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
@@ -293,7 +316,7 @@ class TestTuiPresenterState:
s1 = TuiPresenterState()
s1.render(
Thinking(sse_id=SID, content="x"),
log=MagicMock(),
transcript=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
@@ -310,12 +333,12 @@ class TestTuiPresenterState:
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="partial"),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
)
@@ -323,81 +346,88 @@ class TestTuiPresenterState:
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
)
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("[cancelled]") for w in log_writes)
transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)]
assert any(w.startswith("[cancelled]") for w in transcript_renderables)
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
assert thinking_log.write.call_count >= 3
def test_done_flushes_tail_and_writes_label(self) -> None:
"""done_flushes_tail_and_writes_label [happy, v0.8.2]:
Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
"hi" tail to transcript, then writes [done] label. v0.8.2 drops
the post-Done Markdown body re-render — streamed text is the
canonical content (no double-print).
def test_text_then_done_mounts_widget_and_finalizes(self) -> None:
"""text_then_done_mounts_widget_and_finalizes [happy, v0.9.0]:
First Text delta mounts a Static(Markdown(buffer)) into the transcript;
Done finalizes the widget reference and mounts a styled [done] label.
No duplicate content (v0.9.0 replaces v0.8.x's flush-on-Done with
live in-place Markdown updates).
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
assert not log.write.called
# v0.9.0: response widget mounted on first Text delta with Markdown wrapper.
assert transcript.mount.called
first_widget = transcript.mount.call_args_list[0][0][0]
assert isinstance(first_widget.content, Markdown)
assert first_widget.content.markup == "hi"
assert state.text_chunk_buffer == "hi"
# Done finalizes: text_chunk_buffer cleared, widget ref released, label mounted.
state.render(
_make_tui_done(),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
# On Done: tail flush "hi" + [done] label. No Markdown, no Rule.
writes = [c[0][0] for c in log.write.call_args_list]
assert "hi" in writes
writes = _mounted_renderables(transcript)
assert any(_text_of(w).startswith("[done]") for w in writes)
# v0.8.2: no post-Done re-render — no duplicate content.
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
# v0.9.0: response Markdown rendered live during stream — only ONE
# Markdown renderable lands in the transcript (no post-Done re-render).
markdowns = [w for w in writes if isinstance(w, Markdown)]
assert len(markdowns) == 1
assert state.text_chunk_buffer == ""
assert state.current_response_widget is None
def test_raw_flag_skips_markdown(self) -> None:
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
"""raw_flag_skips_markdown [v0.9.0]: raw=True → response widget holds
plain str instead of Markdown. Live in-place update still happens;
only the wrapper differs.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
state.render(
_make_tui_done(),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=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)
writes = _mounted_renderables(transcript)
# Raw mode bypasses Markdown entirely — content lives as plain str.
assert not any(isinstance(w, Markdown) for w in writes)
assert "hi" in writes
def test_worker_phase_demoted_to_debug_log(self) -> None:
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
@@ -408,17 +438,17 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
assert not log.write.called
assert not transcript.mount.called
renderable = debug_log.write.call_args[0][0]
# INV-003: must be a styled Rich Text renderable, not a plain str.
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
@@ -443,12 +473,12 @@ class TestTuiPresenterState:
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
transcript=transcript,
tools_log=tools_log,
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
)
@@ -456,85 +486,95 @@ class TestTuiPresenterState:
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
# INV-014: transcript was NOT written to
assert not log.write.called
assert not transcript.mount.called
def test_tool_result_routes_to_tools_log(self) -> None:
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
transcript=transcript,
tools_log=tools_log,
debug_log=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
assert not transcript.mount.called
def test_text_event_buffers_until_newline(self) -> None:
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
`\\n` accumulate in text_chunk_buffer; no log write yet.
def test_text_first_delta_mounts_response_widget(self) -> None:
"""text_first_delta_mounts_response_widget [v0.9.0]: first Text delta
mounts a Static carrying Markdown(buffer) into the transcript. The
text_chunk_buffer holds the accumulated content for the next delta's
in-place update.
"""
from rich.markdown import Markdown
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
transcript=transcript,
tools_log=tools_log,
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
# v0.8.1: buffered, not written until `\n` or Done.
assert state.text_chunk_buffer == "hello"
assert not log.write.called
assert transcript.mount.call_count == 1
widget = transcript.mount.call_args[0][0]
assert isinstance(widget.content, Markdown)
assert widget.content.markup == "hello"
assert state.current_response_widget is widget
assert not tools_log.write.called
def test_text_flushes_on_newline(self) -> None:
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
the accumulated buffer as ONE line to log (transcript).
def test_text_subsequent_deltas_update_in_place(self) -> None:
"""text_subsequent_deltas_update_in_place [v0.9.0]: deltas after the
first do NOT mount a new widget — they update the existing widget's
Markdown content in place. The text_chunk_buffer accumulates.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
state = TuiPresenterState()
for tok in ("Hel", "lo", " ", "world", "\n"):
for tok in ("Hel", "lo", " ", "world"):
state.render(
Text(sse_id=SID, content=tok),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
writes = [c[0][0] for c in log.write.call_args_list]
# "Hello world" coalesces to ONE log entry.
assert writes == ["Hello world"]
assert state.text_chunk_buffer == ""
# Exactly ONE mount (the first delta); subsequent deltas update.
assert transcript.mount.call_count == 1
assert state.text_chunk_buffer == "Hello world"
# Widget reference held; buffer is the source of truth re-rendered
# into Markdown(...) for each Static.update call.
assert state.current_response_widget is not None
def test_duration_format_seconds(self) -> None:
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
state = TuiPresenterState()
state.render(
_make_tui_done(duration_ms=5467),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
_text_of(c[0][0])
for c in log.write.call_args_list
if _text_of(c[0][0]).startswith("[done]")
_text_of(r)
for r in _mounted_renderables(transcript)
if _text_of(r).startswith("[done]")
)
assert "duration=5.5s" in done_line
assert "duration_ms=5467" not in done_line
@@ -543,7 +583,7 @@ class TestTuiPresenterState:
"""usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII)."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
transcript = MagicMock()
state = TuiPresenterState()
usage = {
"prompt_tokens": 6756,
@@ -553,14 +593,14 @@ class TestTuiPresenterState:
}
state.render(
_make_tui_done(usage=usage),
log=log,
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
_text_of(c[0][0])
for c in log.write.call_args_list
if _text_of(c[0][0]).startswith("[done]")
_text_of(r)
for r in _mounted_renderables(transcript)
if _text_of(r).startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -571,14 +611,38 @@ def _text_of(write_arg: object) -> str:
Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")`
so the RichLog can apply dim styling; non-demoted writes stay as plain str.
Tests that want to assert against content need both shapes flattened.
v0.9.0: also extracts plain text from Markdown wrappers (the streaming-text
response path uses Markdown(buffer) now; tests assert against the source
markup, which lives in `Markdown.markup`).
"""
from rich.markdown import Markdown
from rich.text import Text as RichText
if isinstance(write_arg, RichText):
return write_arg.plain
if isinstance(write_arg, Markdown):
return write_arg.markup
if isinstance(write_arg, str):
return write_arg
return "" # Markdown / Rule / etc. — not text content
return "" # Rule / etc. — not text content
def _mounted_renderables(transcript_mock: MagicMock) -> list:
"""v0.9.0: TuiPresenterState now mounts Static widgets into the transcript
VerticalScroll instead of writing renderables to a RichLog. Tests using a
MagicMock transcript inspect `transcript.mount.call_args_list`; each call's
first positional arg is the Static child whose `.content` carries the
Markdown / RichText / str that pre-v0.9.0 would have been the write arg.
Returns those renderables in mount-call order so tests can assert on them
with the same shape they used for `log.write.call_args_list` previously.
"""
out: list = []
for call in transcript_mock.mount.call_args_list:
for child in call.args:
renderable = getattr(child, "content", child)
out.append(renderable)
return out
def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
@@ -602,15 +666,15 @@ def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None)
class TestCancelViaSse:
@respx.mock
async def test_happy_cancel(self) -> None:
"""happy_cancel [happy,tracer]: 200 OK → returns None; log has no [cancel_failed]."""
"""happy_cancel [happy,tracer]: 200 OK → returns None; transcript has no [cancel_failed]."""
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
)
log = MagicMock()
transcript = MagicMock()
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await _cancel_via_sse(client, "s-1", 42, log=log)
result = await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
assert result is None
log.write.assert_not_called()
transcript.mount.assert_not_called()
@respx.mock
async def test_cancel_failed_500(self) -> None:
@@ -618,10 +682,10 @@ class TestCancelViaSse:
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(500, content=b"boom")
)
log = MagicMock()
transcript = MagicMock()
async with httpx.AsyncClient(base_url="https://w.example") as client:
await _cancel_via_sse(client, "s-1", 42, log=log)
line = log.write.call_args[0][0]
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
line = transcript.mount.call_args[0][0].content
assert "[cancel_failed]" in line
assert "CancelFailed" in line
@@ -631,10 +695,10 @@ class TestCancelViaSse:
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
)
log = MagicMock()
transcript = MagicMock()
async with httpx.AsyncClient(base_url="https://w.example") as client:
await _cancel_via_sse(client, "s-1", 42, log=log)
line = log.write.call_args[0][0]
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
line = transcript.mount.call_args[0][0].content
assert "[cancel_failed]" in line
assert "CancelAlreadyCompleted" in line
@@ -644,10 +708,10 @@ class TestCancelViaSse:
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
side_effect=httpx.ConnectError("network down")
)
log = MagicMock()
transcript = MagicMock()
async with httpx.AsyncClient(base_url="https://w.example") as client:
await _cancel_via_sse(client, "s-1", 42, log=log)
line = log.write.call_args[0][0]
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
line = transcript.mount.call_args[0][0].content
assert "[cancel_failed]" in line
assert "ConnectError" in line
@@ -714,18 +778,18 @@ class TestLayoutShape:
assert row is not None
async def test_left_column_content_only(self) -> None:
"""left_column_content_only [v0.6.5]: left column = transcript + prompt
+ current-text (streaming text Static). thinking-current Static
removed entirely as of v0.6.5.
"""left_column_content_only [v0.9.0]: left column = transcript-scroll
VerticalScroll + prompt Input. thinking-current Static removed in
v0.6.5; transcript RichLog replaced by VerticalScroll in v0.9.0.
"""
from textual.containers import Vertical
from textual.widgets import Input, RichLog
from textual.containers import Vertical, VerticalScroll
from textual.widgets import Input
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
left = app.query_one("#left-column", Vertical)
transcript = app.query_one("#transcript", RichLog)
transcript = app.query_one("#transcript-scroll", VerticalScroll)
prompt = app.query_one("#prompt", Input)
assert transcript in left.walk_children()
assert prompt in left.walk_children()
@@ -750,7 +814,7 @@ class TestLayoutShape:
assert tools_tab is not None
async def test_tools_log_inside_tools_tab(self) -> None:
"""tools_log_inside_tools_tab: tools-log RichLog is a descendant of tools-tab TabPane."""
"""tools_log_inside_tools_tab: tools-transcript RichLog is a descendant of tools-tab TabPane."""
from textual.widgets import RichLog, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
@@ -801,7 +865,7 @@ class TestLayoutShape:
)
async def test_debug_tab_exists(self) -> None:
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog."""
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-transcript RichLog."""
from textual.widgets import RichLog, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
@@ -823,36 +887,45 @@ class TestLayoutShape:
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
async def test_done_label_styled_success(self) -> None:
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
"""done_label_styled_success [v0.9.0]: [done] label mounts as Static
carrying a RichText with Aurora green style. Inspect the mounted
Static's `.content`.
"""
from rich.text import Text as RichText
from textual.containers import VerticalScroll
from textual.widgets import RichLog
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
# Probe the presenter directly — write a Done via state.render.
from ratatoskr.tui import TuiPresenterState
log = app.query_one("#transcript", RichLog)
transcript = app.query_one("#transcript-scroll", VerticalScroll)
state = TuiPresenterState()
seen: list = []
orig = log.write
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
mounted: list = []
orig_mount = transcript.mount
def spy_mount(*ch, **kw):
mounted.extend(ch)
return orig_mount(*ch, **kw)
transcript.mount = spy_mount # type: ignore[method-assign]
state.render(
_make_tui_done(),
log=log,
transcript=transcript,
tools_log=app.query_one("#tools-log", RichLog),
debug_log=app.query_one("#debug-log", RichLog),
thinking_log=MagicMock(),
raw=True,
)
done = next(
c for c in seen
if isinstance(c, RichText) and _text_of(c).startswith("[done]")
w.content for w in mounted
if isinstance(getattr(w, "content", None), RichText)
and _text_of(w.content).startswith("[done]")
)
assert done.style == "#16B866" # Aurora green
async def test_empty_state_placeholders_present(self) -> None:
"""empty_state_placeholders_present [v0.5.1]: tools-log + debug-log show
"""empty_state_placeholders_present [v0.5.1]: tools-transcript + debug-transcript show
placeholder lines before any turn fires."""
from textual.widgets import RichLog
@@ -1061,11 +1134,13 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
class TestStreamTurnWorker:
@respx.mock
async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_no_double_print [happy,tracer, v0.8.2]:
Text("hello") buffers; on Done, "hello" flushes as tail to transcript
+ [done] label. v0.8.2 drops the post-Done Markdown body re-render
(was double-printing the response — streamed text + Markdown twice).
Only the turn-header Rule remains in the transcript.
"""happy_text_done_no_double_print [happy,tracer, v0.9.0]:
Text("hello") mounts a Static(Markdown("hello")) into the transcript;
Done mounts a [done] label Static. The Markdown is rendered live (one
widget for the whole stream, updated in place), so there is NO
post-Done re-render — exactly ONE Markdown renderable lands in the
transcript for the response body. v0.9.0 supersedes v0.8.2's
drop-Markdown patch with proper live rendering.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
"42:2", _DONE_BODY
@@ -1083,23 +1158,25 @@ class TestStreamTurnWorker:
assert app.state == "idle"
from rich.markdown import Markdown
# "hello" appears as a tail-flush; [done] label fires.
assert any(w == "hello" for w in writes)
assert any("[done]" in str(w) for w in writes)
# v0.8.2: NO Markdown body re-render (was the duplicate).
assert not any(isinstance(w, Markdown) for w in writes)
# The turn-header Rule is written to all 4 panes; we still expect
# SOME Rules in the spy (one per pane), but NOT the post-Done
# separator Rule that pre-v0.8.2 wrote.
# We rely on _spy_writes counting turn-header Rules only.
# The response body lives as ONE Markdown renderable mounted into
# the transcript; live updates happen via Static.update, not via
# re-mount, so there's exactly one Markdown in the spy stream.
markdowns = [w for w in writes if isinstance(w, Markdown)]
assert len(markdowns) == 1, (
f"v0.9.0: expected exactly ONE Markdown mounted, got {len(markdowns)}"
)
assert markdowns[0].markup == "hello"
# [done] label fires too.
assert any("[done]" in _text_of(w) 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, 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.
"""raw_flag_skips_markdown_render [trace, v0.9.0]:
With --raw, the response widget holds plain str instead of Markdown.
Turn-header markers still appear in every pane: 3 RichLog panes
receive a Rule, the transcript-scroll receives a Static-wrapped
RichText (mounted, not written), giving 3 Rules in the captured
writes list.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
"42:2", _DONE_BODY
@@ -1117,11 +1194,11 @@ class TestStreamTurnWorker:
# No Markdown in raw mode.
assert not any(isinstance(w, Markdown) for w in writes)
# Only turn-header Rules — one per pane (transcript + tools +
# debug + thinking = 4). No post-Done separator Rule.
# 3 Rules — one per RichLog pane (tools / debug / thinking).
# Transcript-scroll uses a Static turn-header Markdown alternative.
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 len(rules) == 3, f"expected 3 turn-header Rules, got {len(rules)}"
# Accumulated text "hi" mounted as plain str into transcript.
assert "hi" in writes
@respx.mock
@@ -1495,8 +1572,14 @@ class TestActionInterrupt:
await pilot.pause(0.02)
# Give _cancel_via_sse time to write the [cancel_failed] line
await pilot.pause(0.05)
log = app.query_one("#transcript", RichLog)
rendered = "\n".join(str(strip.text) for strip in log.lines)
from textual.containers import VerticalScroll
from textual.widgets import Static
transcript = app.query_one("#transcript-scroll", VerticalScroll)
rendered = "\n".join(
str(child.content)
for child in transcript.children
if isinstance(child, Static)
)
assert "[cancel_failed]" in rendered
assert app.state == "cancelling"
stream_gate.set() # let stream finish for teardown