refactor(tui): thinking streams into whole pane (v0.6.5)

Operator: "Why does the thinking scroll a little section at the
bottom of the thinking pane instead of scrolling the whole pane?"

Root cause: v0.6.1's thinking-current Static was docked to the
bottom of the Thinking pane and rendered the last 200 chars of
streaming content. As deltas arrived, the displayed 200-char tail
shifted — old text fell off the left, new text appeared on the
right — visually reading as "a little section scrolling at the
bottom" while the larger thinking-log RichLog above showed only
the previous run's closed content (or nothing on first turn).

## Fix: stream directly into thinking-log

The Static is gone. Thinking deltas now write straight to the
`thinking-log` RichLog (one delta = one line in the scrollable
log). The whole pane scrolls naturally as content arrives —
operator can switch to Ctrl+3 and see streaming content fill
the pane top-to-bottom.

Routing pattern:

  First Thinking delta of run:
    → write Rule(title="turn N · thinking #K start") to thinking_log
    → write delta content as a line
    → set thinking_open = True
  Subsequent Thinking deltas:
    → write delta content as a line
  Non-thinking event (closes the run):
    → write Rule(title="turn N · thinking #K end") to thinking_log
    → reset thinking_open

The Rule(start) at the top of an in-progress run is now the
"thinking is happening" indicator. No more separate live-preview
widget required.

## Trade-off: no markdown re-render

Pre-v0.6.5 closed runs got a Markdown(full_content) render between
the start/end Rules. v0.6.5 drops that — the streamed deltas ARE
the content; re-rendering as Markdown would either need to wait
for run-end (no streaming) OR re-render incrementally per delta
(bad UX). Streaming wins for "live observability" framing.

The downside: if model thinking has Markdown structure (lists,
code), it renders as raw text. Acceptable per operator's "stream
in line" framing.

## Removed widgets

- `Static#thinking-current` (right column / Thinking pane bottom)
- `TuiPresenterState.render` no longer takes a `thinking_widget` param
- `TuiPresenterState.thinking_buffer` field dropped (no accumulation)
- `_stream_turn_worker` no longer queries `#thinking-current`
- `on_mount` no longer hides `#thinking-current`
- DEFAULT_CSS `#thinking-current` block removed

## Contract amendment

INV-022 amended: thinking now streams as raw delta lines, not
Markdown-rendered on close. INV-024 amended: thinking-current
Static removed entirely (was relocated v0.6.1, removed v0.6.5).
Drift-check clean.

## Tests

238/238 GREEN (was 241 — 3 obsolete widget tests deleted:
test_thinking_widget_truncation, test_thinking_widget_visibility_lifecycle,
test_terminal_events_belt_and_braces_widget_cleanup). 5 routing tests
rewritten for the new streaming shape (test_thinking_streams_into_thinking_log,
test_thinking_closes_to_thinking_log, test_multiple_thinking_runs_...,
test_render_exception_fallback, test_cancelled_mid_thinking_closes,
test_left_column_content_only).

ruff clean. Manual injection test confirms routing: Rule(start) +
delta lines write to thinking_log; transcript untouched.

Patch bump (v0.6.4 → v0.6.5) — internal restructure within Thinking
pane; presenter signature narrowed; no caller-visible public API
change (RatatoskrApp + AgentPickerApp surfaces identical).
This commit is contained in:
vh
2026-05-24 19:00:58 -07:00
parent 82437bd4b9
commit d3569904bc
6 changed files with 125 additions and 266 deletions
+84 -193
View File
@@ -11,7 +11,6 @@ from ratatoskr.cli import ParsedArgs
from ratatoskr.sse_client import (
Cancelled,
Done,
Error,
SseId,
Text,
Thinking,
@@ -116,56 +115,43 @@ SID = SseId(42, 5)
class TestTuiPresenterState:
"""Tests for the new TuiPresenterState — per issue #12 contract."""
def test_thinking_coalesce_single_widget_update(self) -> None:
"""thinking_coalesce_single_widget_update [happy,tracer]:
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
RichLog has 0 thinking entries (closure hasn't fired yet).
def test_thinking_streams_into_thinking_log(self) -> None:
"""thinking_streams_into_thinking_log [happy,tracer, v0.6.5]:
3 Thinking deltas → thinking_log gets Rule(start) + 3 delta lines.
Transcript untouched; no thinking-current Static involved.
"""
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# Widget updated 3 times — once per delta — with cumulative content
assert widget.update.call_count == 3
# Latest call shows the full accumulated content (under 200 chars so no truncation).
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
# Widget became visible at first delta
assert widget.display is True
# No RichLog write yet — closure hasn't fired
for chunk in ("a", "b", "c"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
writes = [c[0][0] for c in thinking_log.write.call_args_list]
# 1 Rule(start) + 3 content lines = 4 writes
assert len(writes) == 4
assert isinstance(writes[0], Rule)
assert writes[1] == "a"
assert writes[2] == "b"
assert writes[3] == "c"
# Transcript untouched during thinking streaming.
assert log.write.call_count == 0
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.
"""thinking_closes_to_thinking_log [happy, v0.6.5]: 2x Thinking + WorkerPhase →
thinking_log gets Rule(start) + 2 delta lines + Rule(end); debug_log gets
the worker_phase line; transcript untouched.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
@@ -173,13 +159,11 @@ class TestTuiPresenterState:
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
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(),
@@ -189,87 +173,32 @@ class TestTuiPresenterState:
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.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
# 1 Rule(start) + 2 delta lines + 1 Rule(end) = 4 writes
assert len(thinking_writes) == 4
assert isinstance(thinking_writes[0], Rule)
assert thinking_writes[1] == "a"
assert thinking_writes[2] == "b"
assert isinstance(thinking_writes[3], 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
# Widget cleared + hidden
widget.update.assert_called_with("")
assert widget.display is False
def test_thinking_widget_truncation(self) -> None:
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "…" + last 200."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
# Push 500 chars across multiple deltas.
long = "x" * 500
state.render(
Thinking(sse_id=SID, content=long),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
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.
assert last_update.startswith("thinking… ")
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
assert len(last_update) == len("thinking… ") + 201
assert "…" in last_update
def test_thinking_widget_visibility_lifecycle(self) -> None:
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
hidden after closing event.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
widget.display = False # initial state (composed hidden)
state = TuiPresenterState()
# First thinking delta → widget visible
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert widget.display is True
# Closure (WorkerPhase) → widget hidden
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert widget.display is False
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
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.
"""multiple_thinking_runs_each_get_section [scenario, v0.6.5]:
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
thinking_log, each wrapping their delta lines. Text goes to
current_text (buffered). Transcript: [done] + Markdown body.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
@@ -277,7 +206,6 @@ class TestTuiPresenterState:
log = MagicMock()
thinking_log = MagicMock()
current_text = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
for evt in (
Thinking(sse_id=SID, content="first"),
@@ -285,22 +213,23 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="second"),
):
state.render(
evt, log=log, thinking_widget=widget,
evt, log=log,
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,
log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
)
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
# v0.6.5: thinking_log holds 4 Rules (start + end per run) + 2 delta lines.
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)]
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
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)}"
assert "first" in delta_strs
assert "second" in delta_strs
# 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).
@@ -308,22 +237,25 @@ class TestTuiPresenterState:
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.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.
"""render_exception_fallback [adversarial, v0.6.5]:
thinking_log.write raises → catch in presenter, write plain-label
fallback + render_error line via INV-009 fallback path (routing
preservation: thinking events still route to thinking_log).
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
thinking_log = MagicMock()
widget = MagicMock()
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
# First call (Rule write) raises; subsequent calls succeed for fallback.
thinking_log.write.side_effect = [
AttributeError("rule write failed (msg should NOT leak)"),
None,
None,
]
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(),
@@ -333,7 +265,7 @@ class TestTuiPresenterState:
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
assert any(w == "[render_error] AttributeError" for w in writes), writes
assert not any("widget gone" in w for w in writes), writes
assert not any("rule write failed" in w for w in writes), writes
assert not log.write.called
def test_state_reset_per_worker(self) -> None:
@@ -344,10 +276,11 @@ class TestTuiPresenterState:
s1.render(
Thinking(sse_id=SID, content="x"),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
current_text=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
s2 = TuiPresenterState()
assert s1.thinking_open is True
@@ -361,32 +294,29 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
widget = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="partial"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
)
state.render(
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
)
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
# Rule start/end). terminal [cancelled] still in transcript.
# 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)
assert widget.display is False
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
assert thinking_log.write.call_count >= 3
def test_done_renders_markdown_after_label(self) -> None:
"""done_renders_markdown_after_label [happy, v0.6.0]:
@@ -401,12 +331,10 @@ class TestTuiPresenterState:
log = MagicMock()
current_text = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
@@ -418,7 +346,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
@@ -440,12 +367,10 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -453,7 +378,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -477,7 +401,6 @@ class TestTuiPresenterState:
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
@@ -494,36 +417,10 @@ class TestTuiPresenterState:
assert text.startswith("· worker_phase:")
assert "[worker_phase]" not in text
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
Done / Error / Cancelled MUST clear+hide the thinking widget even when
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
"""
from ratatoskr.tui import TuiPresenterState
for terminal in (
_make_tui_done(),
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
),
):
log = MagicMock()
widget = MagicMock()
widget.display = True # pre-set to non-default to detect the clear
state = TuiPresenterState()
# thinking_open is False (state just constructed).
state.render(
terminal,
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
widget.update.assert_called_with("")
assert widget.display is False, type(terminal).__name__
# v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted.
# The thinking-current Static is gone, so there's no widget to clean up
# on terminal events. The corresponding Volva F3 invariant is obsoleted
# by the streaming-into-thinking_log architecture.
def test_tool_start_routes_to_tools_log(self) -> None:
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
@@ -540,7 +437,6 @@ class TestTuiPresenterState:
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
@@ -561,7 +457,6 @@ class TestTuiPresenterState:
state.render(
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
@@ -583,7 +478,6 @@ class TestTuiPresenterState:
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=current_text,
@@ -606,7 +500,6 @@ class TestTuiPresenterState:
state.render(
Text(sse_id=SID, content=tok),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
@@ -625,7 +518,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(duration_ms=5467),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -653,7 +545,6 @@ class TestTuiPresenterState:
state.render(
_make_tui_done(usage=usage),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
@@ -815,27 +706,28 @@ class TestLayoutShape:
assert row is not None
async def test_left_column_content_only(self) -> None:
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
thinking-current Static moved to right column so the left column is
genuinely content-only (transcript + prompt input).
"""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.
"""
from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static
from textual.widgets import Input, RichLog
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)
right = app.query_one("#right-column", Vertical)
transcript = app.query_one("#transcript", RichLog)
prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static)
assert transcript in left.walk_children()
assert prompt in left.walk_children()
# v0.5.0: thinking-current is now under the right column, NOT left.
assert thinking not in left.walk_children()
assert thinking in right.walk_children()
# v0.6.5: thinking-current Static removed; no longer in DOM at all.
from textual.css.query import NoMatches
try:
app.query_one("#thinking-current")
raise AssertionError("thinking-current should not exist in v0.6.5")
except NoMatches:
pass # expected
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
@@ -940,7 +832,6 @@ class TestLayoutShape:
state.render(
_make_tui_done(),
log=log,
thinking_widget=app.query_one("#thinking-current"),
tools_log=app.query_one("#tools-log", RichLog),
debug_log=app.query_one("#debug-log", RichLog),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,