diff --git a/docs/contracts/issues/13.contract.md b/docs/contracts/issues/13.contract.md index d4975f7..0312eb0 100644 --- a/docs/contracts/issues/13.contract.md +++ b/docs/contracts/issues/13.contract.md @@ -161,9 +161,9 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w - **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically. - **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_log`; everything else → `log`. - **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired. -- **INV-022** *(new v0.6.0)*: Closed thinking runs route to `thinking_log`, NOT `debug_log`. Each closed run writes three entries: `Rule(title=f"turn N · thinking #K start")`, `Markdown(content)`, `Rule(title=f"turn N · thinking #K end")` — the model's chain-of-thought is presented as rendered Markdown (model reasoning often has lists / code / structure) wrapped in operator-visible start/end markers. `thinking_run_index` increments per-run within a turn. +- **INV-022** *(amended v0.6.5)*: Thinking deltas stream DIRECTLY into `thinking_log` (one delta = one RichLog line). The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas write their raw content as lines; the run closes on the next non-thinking event with `Rule(title=f"turn N · thinking #K end")`. Pre-v0.6.5 markdown re-render dropped — the streamed deltas ARE the content; the whole pane scrolls naturally as content arrives. - **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging. -- **INV-024** *(amended v0.6.1)*: `thinking-current` Static lives INSIDE the Thinking TabPane (docked bottom, below `thinking-log`) — co-located with closed thinking runs so the operator sees streaming + history in one pane. Pre-v0.6.1 it sat above the TabbedContent (right-column header) which created a top/bottom discontinuity; the co-located shape resolves that. Trade-off: live thinking is now visible only when the Thinking tab is active (Ctrl+3). Prefix `thinking… ` self-identifies the widget contents. +- **INV-024** *(amended v0.6.5)*: `thinking-current` Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into `thinking_log` (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator. ## TESTS (additions / changes to test_tui.py) diff --git a/persistent-memory.md b/persistent-memory.md index 2db1016..04e4240 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool. ## Current state / in-flight -_As of 2026-05-24 (post-v0.6.4 picker Aurora-blue highlight):_ +_As of 2026-05-24 (post-v0.6.5 thinking streams into whole pane):_ -**Status: v0.6.4 shipped.** Nine core issues complete (`sse_client` +**Status: v0.6.5 shipped.** Nine core issues complete (`sse_client` #1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI startup error visibility #6, presenter contract semantics amendment #12, startup agent picker #8, §5 layout reshape + Tools pane #13) @@ -51,7 +51,8 @@ Static in the footer (static "Tools" v1; dynamic when more tabs land). CLI mode (--send) unaffected by design — INV-018. Last commits on `main`: -- v0.6.4 style(tui): picker highlighted item → Aurora blue (brand selection treatment) +- v0.6.5 refactor(tui): thinking streams into thinking-log (no Static) +- `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4) - `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3) - `d845b20` style(tui): neutralize Australis dark palette (v0.6.2, reverted) - `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1) diff --git a/pyproject.toml b/pyproject.toml index 8de5235..7d42187 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.6.4" +version = "0.6.5" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index 7e80837..f65fc57 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -185,12 +185,11 @@ class TuiPresenterState: See `docs/contracts/issues/12.contract.md` for the full spec. """ - thinking_buffer: list[str] = field(default_factory=list) thinking_open: bool = False # v0.6.0: per-turn streaming text buffer. Text deltas accumulate here # and update `current_text` Static in place — no per-token RichLog spam. text_buffer: list[str] = field(default_factory=list) - # v0.6.0: thinking-run counter for turn-scoped start/end markers. + # Thinking-run counter for turn-scoped start/end markers. thinking_run_index: int = 0 def render( @@ -198,7 +197,6 @@ class TuiPresenterState: event: Event, *, log: RichLog, - thinking_widget: Static, current_text: Static, tools_log: RichLog, debug_log: RichLog, @@ -207,16 +205,18 @@ class TuiPresenterState: ) -> None: """Render one Worldtree SSE event with the TUI hierarchy + coalescing. - v0.6.0 routing: + v0.6.5 routing: - `log` (transcript) = content only: user-prompt echo (written outside the presenter), terminal labels, post-Done Markdown body. - `current_text` (Static below transcript) = live-streaming Text deltas accumulated into one growing line; cleared on terminal. - `tools_log` = ToolStart + ToolResult. - `debug_log` = WorkerPhase + TextBoundary. - - `thinking_log` = closed thinking runs (Markdown + start/end - Rule markers); `thinking_widget` continues to receive live - per-delta updates. + - `thinking_log` = streaming Thinking deltas inline (each chunk = + one line in the scrollable log). Rule(start)/Rule(end) markers + wrap each run. The whole pane scrolls naturally — no separate + tail-scrolling Static at the bottom (v0.6.5 removed + `thinking-current`). Exceptions caught at the presenter boundary (INV-009 fallback). """ @@ -230,56 +230,40 @@ class TuiPresenterState: from rich.text import Text as RichText def _dim(s: str) -> RichText: - """Wrap a demoted-telemetry line in Australis dark-60 grey. - - v0.4.1 retheme: was `style="dim"` (terminal-dim filter, varies by - emulator); now explicit Australis Sea dark-60 (#86929d) so the - shade renders consistently across terminals and stays anchored to - the brand palette. - """ + """Wrap a demoted-telemetry line in Australis Sea dark-60 grey.""" return RichText(s, style=_AU_DEMOTED) try: - # Thinking events: accumulate into buffer, update widget per delta. + # v0.6.5: Thinking deltas stream directly into thinking_log. + # First delta of a run writes the Rule(start) header; each + # subsequent delta writes its content as a line; the run closes + # on the next non-thinking event with a Rule(end). if isinstance(event, Thinking): + from rich.rule import Rule + if not self.thinking_open: - thinking_widget.display = True + self.thinking_run_index += 1 + turn_id = event.sse_id.turn_id + thinking_log.write(Rule( + title=f"turn {turn_id} · thinking #{self.thinking_run_index} start", + style=_AU_DEMOTED, + )) self.thinking_open = True - self.thinking_buffer.append(event.content) - acc = "".join(self.thinking_buffer) - # v0.5.1 polish: prefix the live widget with "thinking… " so - # operators recognize what the streaming content is (otherwise - # the static-content under Header reads like uncontextualized - # spillover). Truncate display to last 200 chars + ellipsis. - tail = ("…" + acc[-200:]) if len(acc) > 200 else acc - thinking_widget.update(f"thinking… {tail}") + # Stream the delta content (chunk-of-tokens) as one line. + thinking_log.write(event.content) return - # Non-thinking event: close any open thinking run. - # v0.6.0: closed thinking runs route to thinking_log (Thinking - # pane) wrapped in `── turn N · thinking start/end ──` Rule - # markers, with the content itself rendered as Markdown (model - # reasoning often has lists, code, structure). + # Non-thinking event: close any open thinking run with Rule(end). if self.thinking_open: - full_thinking = "".join(self.thinking_buffer) + from rich.rule import Rule + turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else ( event.turn_id if hasattr(event, "turn_id") else "?" ) - self.thinking_run_index += 1 - from rich.markdown import Markdown - from rich.rule import Rule - thinking_log.write(Rule( - title=f"turn {turn_id} · thinking #{self.thinking_run_index} start", - style=_AU_DEMOTED, - )) - thinking_log.write(Markdown(full_thinking)) thinking_log.write(Rule( title=f"turn {turn_id} · thinking #{self.thinking_run_index} end", style=_AU_DEMOTED, )) - self.thinking_buffer.clear() self.thinking_open = False - thinking_widget.update("") - thinking_widget.display = False # Now render the non-thinking event itself. if isinstance(event, Text): # v0.6.0: streaming text accumulates into current_text Static @@ -327,10 +311,6 @@ class TuiPresenterState: f"partial_message_id={event.partial_message_id}", style=_AU_WARNING, )) - # Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY - # terminal event, even if thinking_open was False — per STEPS 5-6. - thinking_widget.update("") - thinking_widget.display = False return if isinstance(event, WorkerPhase): # v0.5.0: telemetry → Debug pane, not transcript. @@ -564,18 +544,8 @@ class RatatoskrApp(App[int]): #right-column { width: 1fr; } - /* v0.6.1: thinking-current Static moved INTO the Thinking pane (below - thinking-log) so streaming + closed runs co-locate. Docked bottom of - its TabPane so it acts as the live "tail" of the chronological log - above. Empty (height:0) when no thinking is active. */ - #thinking-current { - dock: bottom; - height: auto; - color: $au-dark-60; - padding: 0 1; - text-style: italic; - background: $background; - } + /* v0.6.5: thinking-current Static removed; thinking now streams + directly into thinking-log so the whole pane scrolls naturally. */ #transcript { height: 1fr; background: $background; @@ -703,14 +673,15 @@ class RatatoskrApp(App[int]): id="debug-log", wrap=True, markup=False, highlight=False ) with TabPane("Thinking", id="thinking-tab"): + # v0.6.5: thinking streams directly into this + # RichLog (no separate bottom Static). Each delta + # writes a line; Rule(start)/Rule(end) mark run + # boundaries. The whole pane scrolls naturally + # as content arrives — no more "200-char tail + # window scrolling at the bottom". yield RichLog( id="thinking-log", wrap=True, markup=False, highlight=False ) - # v0.6.1: live thinking lives INSIDE the Thinking - # pane (docked bottom) — co-located with the closed - # runs in thinking-log above. No more top/bottom - # discontinuity across the right column. - yield Static("", id="thinking-current") # INV-002 + INV-003: visible identity + hint widgets (Footer-area). # pane-name widget displays current side-pane name. yield Static("", id="identity") @@ -729,8 +700,6 @@ class RatatoskrApp(App[int]): identity = f"{agent_slot} · …{self.session_id[-8:]}" self.sub_title = identity # mirror to Header subtitle for redundancy self.query_one("#identity", Static).update(identity) - # Issue #12: thinking widget hidden until a thinking event fires. - self.query_one("#thinking-current", Static).display = False # v0.5.1 polish: empty-state placeholder lines so the operator sees # the pane is intentionally empty (not broken) before any turn fires. # Wrapped in Australis dark-50 italic so they read distinctly as @@ -811,7 +780,6 @@ class RatatoskrApp(App[int]): assert self.client is not None assert content log = self.query_one("#transcript", RichLog) - thinking_widget = self.query_one("#thinking-current", Static) current_text = self.query_one("#current-text", Static) tools_log = self.query_one("#tools-log", RichLog) debug_log = self.query_one("#debug-log", RichLog) @@ -828,7 +796,6 @@ class RatatoskrApp(App[int]): presenter.render( event, log=log, - thinking_widget=thinking_widget, current_text=current_text, tools_log=tools_log, debug_log=debug_log, diff --git a/tests/test_tui.py b/tests/test_tui.py index 69c395b..f666b45 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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, diff --git a/uv.lock b/uv.lock index a6e9a45..23e3f2f 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.6.4" +version = "0.6.5" source = { editable = "." } dependencies = [ { name = "httpx" },