diff --git a/docs/contracts/issues/13.contract.md b/docs/contracts/issues/13.contract.md index b2ae491..a357fbe 100644 --- a/docs/contracts/issues/13.contract.md +++ b/docs/contracts/issues/13.contract.md @@ -154,10 +154,12 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w - **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr. - **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript). -- **INV-015**: Every other event type (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keeps its issue-#12 routing target (`log` for chronological entries; `thinking_widget` for live deltas). -- **INV-016**: Input retains keyboard focus across `Ctrl+1` tab switch. -- **INV-017**: `thinking-current` Static docks to the top of the **left column**, not the whole App — TabbedContent's vertical extent on the right is independent of thinking-runs starting/stopping. +- **INV-015** *(amended v0.5.0)*: The transcript (`log`) is **content-only** — receives `Text`, `Done` (label + Markdown body + Rule), `Error`, `Cancelled`, and the user-prompt echo (`❯ `). **All telemetry events (`Thinking` closed runs, `WorkerPhase`, `TextBoundary`) route to `debug_log` (Debug pane), NOT `log`.** Live thinking deltas continue to update `thinking_widget` per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule. +- **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches. +- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only. - **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005. +- **INV-019** *(new v0.5.0)*: Two TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`). Ctrl+1 activates Tools; Ctrl+2 activates Debug. `pane-name` Static reflects the active tab name dynamically. +- **INV-020** *(new v0.5.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` fallback writes to `tools_log`; `WorkerPhase` / `Thinking` / `TextBoundary` fallback writes to `debug_log`; everything else falls back to `log`. ## TESTS (additions / changes to test_tui.py) @@ -185,21 +187,28 @@ Existing tests that need adjustment (NOT rewrite): All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch. -## Layout-spec snapshot (after this issue lands) +## Layout-spec snapshot (after v0.5.0) ``` +────────────────────────────────+──────────────────────+ -| · thinking-current | ┌─ Tools ─────────┐ | -| | │ · tool_start:.. │ | -| ❯ user-typed line | │ · tool_result.. │ | -| assistant streaming text... | │ │ | -| [done] turn_id=… duration=… | │ │ | -| | │ │ | -| | │ │ | -| [prompt: type and press Enter]| └─────────────────┘ | +| ❯ user-typed line | · thinking-current | +| assistant streaming text... | ┌Tools─┬─Debug─────┐| +| [done] turn_id=… duration=… | │ · tool_start:.. │| +| …markdown render… | │ · tool_result.. │| +| | │ │| +| | │ │| +| | │ │| +| [prompt: type and press Enter]| └──────────────────┘| +────────────────────────────────+──────────────────────+ | agent · …sess_id Tools Ctrl-C twice to exit | +───────────────────────────────────────────────────────+ ``` -(Width split 2fr:1fr; tab strip is Textual-default.) +Left column is content-only (transcript + prompt). Right column hosts the +live `thinking-current` Static at top + `TabbedContent` cycling between +`Tools` (tool events) and `Debug` (thinking closed runs + worker_phase + +text_boundary). + +(Width split 2fr:1fr; tab strip is Textual-default; Header/Footer +backgrounds explicitly set to `$surface` to override the Textual default +`$primary`-blue tinting.) diff --git a/persistent-memory.md b/persistent-memory.md index 7fe26e5..18609ae 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.4.1 Australis theme retheme):_ +_As of 2026-05-24 (post-v0.5.0 content-only main pane + Debug tab):_ -**Status: v0.4.1 shipped.** Nine core issues complete (`sse_client` +**Status: v0.5.0 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.4.1 style(tui): apply Australis theme to TUI chrome + widgets +- v0.5.0 refactor(tui): content-only main pane + Debug tab + chrome dark +- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1) - `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0) - `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0) - `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1) diff --git a/pyproject.toml b/pyproject.toml index fed6dd1..5c68931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.4.1" +version = "0.5.0" 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 a2577d5..88c9bc3 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -186,16 +186,17 @@ class TuiPresenterState: log: RichLog, thinking_widget: Static, tools_log: RichLog, + debug_log: RichLog, raw: bool, ) -> None: """Render one Worldtree SSE event with the TUI hierarchy + coalescing. - Two-views-of-thinking decoupling: per-delta updates go to - `thinking_widget`; one closed entry per run goes to `log`. - - Issue #13: `ToolStart` / `ToolResult` events route to `tools_log` - (the Tools pane in the right column) instead of `log`. Every other - event keeps its issue-#12 routing. + v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text, + terminal labels ([done] / [error] / [cancelled]), and the + post-Done Markdown render. All telemetry (Thinking closed runs, + WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all + tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools + tab). Live thinking deltas continue to update `thinking_widget`. Exceptions are caught at the presenter boundary (INV-009 fallback). """ @@ -229,10 +230,12 @@ class TuiPresenterState: display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc thinking_widget.update(display_text) return - # Non-thinking event: close any open thinking run (one RichLog entry). + # Non-thinking event: close any open thinking run. + # v0.5.0: closed thinking runs land in debug_log (Debug pane), not + # transcript — keeps the main pane content-only. if self.thinking_open: full_thinking = "".join(self.thinking_buffer) - log.write(_dim(f"· thinking: {full_thinking}")) + debug_log.write(_dim(f"· thinking: {full_thinking}")) self.thinking_buffer.clear() self.thinking_open = False thinking_widget.update("") @@ -272,7 +275,8 @@ class TuiPresenterState: thinking_widget.display = False return if isinstance(event, WorkerPhase): - log.write(_dim( + # v0.5.0: telemetry → Debug pane, not transcript. + debug_log.write(_dim( f"· worker_phase: phase={event.phase} turn_id={event.turn_id}" )) return @@ -290,7 +294,8 @@ class TuiPresenterState: )) return if isinstance(event, TextBoundary): - log.write(_dim( + # v0.5.0: telemetry → Debug pane, not transcript. + debug_log.write(_dim( f"· text_boundary: kind={event.kind} char_offset={event.char_offset}" )) return @@ -299,10 +304,17 @@ class TuiPresenterState: # the original event AND a render_error line with the class name only # (NO exception message — security clause). Volva F1 fix. # - # Issue #13: routing-under-failure preservation — ToolStart/ToolResult - # fallback writes go to tools_log (the routed destination per - # INV-014), not the transcript. Every other event falls back to log. - target = tools_log if isinstance(event, (ToolStart, ToolResult)) else log + # v0.5.0: routing-under-failure preservation — fallback writes go + # to the same destination the successful render would have used: + # - ToolStart/ToolResult → tools_log + # - WorkerPhase/Thinking/TextBoundary → debug_log + # - everything else (Text/Done/Error/Cancelled) → log + if isinstance(event, (ToolStart, ToolResult)): + target = tools_log + elif isinstance(event, (WorkerPhase, Thinking, TextBoundary)): + target = debug_log + else: + target = log target.write(_plain_label(event)) target.write(f"[render_error] {type(exc).__name__}") @@ -380,20 +392,32 @@ class AgentPickerApp(App[str | None]): class RatatoskrApp(App[int]): """Textual TUI shell — single chat pane.""" - # Issue #13: Horizontal two-column layout per design-brief §5. - # Left column (2fr) is the chat surface; right column (1fr) is the - # TabbedContent housing side panes. v1 has only the Tools tab. + # Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per + # design-brief §5. Left column (2fr) is the **content-only** chat + # surface — assistant text, user prompt echo, [done]/[error]/[cancelled] + # terminal labels, post-Done markdown render. Right column (1fr) houses + # ALL telemetry: live thinking preview docked above TabbedContent; + # tab strip carries Tools (ToolStart/ToolResult) + Debug (Thinking + # closed runs + WorkerPhase + TextBoundary). # - # Dock rules narrow to per-container scope so thinking-current toggling - # in the left column doesn't reflow the right column's TabbedContent. - # The v0.2.1 layout-stability property is preserved within the left - # column by docking thinking-current top + prompt bottom of that column. + # v0.5.0 routing change: thinking-current Static moved from left column + # to right column header so the left column is genuinely content-only; + # closed thinking runs go to debug-log instead of transcript. # - # v0.4.1: Australis theme — Aurora blue/cyan primary, Ice background, - # Sea darks for chrome separators. Widget colors use theme variables - # ($primary/$accent/$au-dark-60/$au-bright-cyan/etc.) so a future theme - # swap rebinds them centrally. + # v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface + # (Sea bright-black #373b46) overriding Textual's default $primary-blue + # tinting. TabbedContent active-tab tinting also softened. + # + # Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/ + # etc.) carry colors so a future theme swap rebinds centrally. DEFAULT_CSS = """ + Header { + background: $surface; + color: $au-bright-blue; + } + Footer { + background: $surface; + } #main-row { height: 1fr; } @@ -408,14 +432,18 @@ class RatatoskrApp(App[int]): dock: top; height: auto; color: $au-dark-60; + padding: 0 1; } #transcript { height: 1fr; background: $background; } - #tools-log { + #tools-log, #debug-log { background: $background; } + #side-panes Tabs { + background: $surface; + } #prompt { dock: bottom; border: tall $panel; @@ -446,10 +474,10 @@ class RatatoskrApp(App[int]): BINDINGS: ClassVar[list[Binding]] = [ Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True), Binding("ctrl+d", "quit", "Exit immediately", priority=True), - # Issue #13: §5 keybinding family Ctrl+1..5 jumps between side panes - # without losing Input focus (INV-016). v1 only has Tools; Ctrl+2..5 - # land as Persona/AdminEvents/BifrostState/ServerLog panes ship. + # §5 keybinding family Ctrl+1..5 jumps between side panes + # without losing Input focus (INV-016). Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), + Binding("ctrl+2", "focus_debug", "Debug tab", priority=False), ] HINT_IDLE = "Ctrl-C twice to exit" @@ -478,9 +506,9 @@ class RatatoskrApp(App[int]): def compose(self) -> ComposeResult: yield Header() - # Issue #13: Horizontal two-column layout. Left column = chat surface - # (thinking-current docked top, transcript fills middle, prompt docked - # bottom). Right column = TabbedContent for side panes (v1: Tools only). + # v0.5.0 layout: left column is content-only (transcript + prompt). + # Right column houses ALL telemetry — thinking-current live preview + # docked above the TabbedContent; tabs cycle Tools / Debug. # markup=False on RichLog so labeled lines like "[cancel_failed] ..." # render verbatim; Rich would otherwise interpret bracket spans as # style markup. The post-Done markdown render uses Markdown() directly @@ -488,17 +516,21 @@ class RatatoskrApp(App[int]): # widget-level markup=True. with Horizontal(id="main-row"): with Vertical(id="left-column"): - yield Static("", id="thinking-current") yield RichLog(id="transcript", wrap=True, markup=False, highlight=False) yield Input(id="prompt", placeholder="Type a message and press Enter") with Vertical(id="right-column"): + yield Static("", id="thinking-current") with TabbedContent(id="side-panes"): with TabPane("Tools", id="tools-tab"): yield RichLog( id="tools-log", wrap=True, markup=False, highlight=False ) + with TabPane("Debug", id="debug-tab"): + yield RichLog( + id="debug-log", wrap=True, markup=False, highlight=False + ) # INV-002 + INV-003: visible identity + hint widgets (Footer-area). - # INV (issue #13): pane-name widget displays current side-pane name. + # pane-name widget displays current side-pane name. yield Static("", id="identity") yield Static("Tools", id="pane-name") yield Static(self.HINT_IDLE, id="hint") @@ -559,8 +591,9 @@ class RatatoskrApp(App[int]): assert content log = self.query_one("#transcript", RichLog) thinking_widget = self.query_one("#thinking-current", Static) - # Issue #13: tools_log routes ToolStart/ToolResult into the Tools pane. + # v0.5.0: separate panes for tools vs telemetry; transcript is content only. tools_log = self.query_one("#tools-log", RichLog) + debug_log = self.query_one("#debug-log", RichLog) presenter = TuiPresenterState() try: async for event in stream_turn(self.client, self.session_id, content): @@ -571,6 +604,7 @@ class RatatoskrApp(App[int]): log=log, thinking_widget=thinking_widget, tools_log=tools_log, + debug_log=debug_log, raw=self.args.raw, ) if isinstance(event, (Done, Error, Cancelled)): @@ -631,6 +665,12 @@ class RatatoskrApp(App[int]): — `test_ctrl_1_preserves_input_focus` is the regression guard. """ self.query_one("#side-panes", TabbedContent).active = "tools-tab" + self.query_one("#pane-name", Static).update("Tools") + + def action_focus_debug(self) -> None: + """v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus.""" + self.query_one("#side-panes", TabbedContent).active = "debug-tab" + self.query_one("#pane-name", Static).update("Debug") def run_tui(args: ParsedArgs) -> int: diff --git a/tests/test_tui.py b/tests/test_tui.py index a724c04..9be2a6f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -126,6 +126,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) state.render( @@ -133,6 +134,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) state.render( @@ -140,6 +142,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) # Widget updated 3 times — once per delta — with cumulative content @@ -151,13 +154,15 @@ class TestTuiPresenterState: # No RichLog write yet — closure hasn't fired assert log.write.call_count == 0 - def test_thinking_closes_one_richlog_entry(self) -> None: - """thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase → - RichLog has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden. + 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. """ from ratatoskr.tui import TuiPresenterState log = MagicMock() + debug_log = MagicMock() widget = MagicMock() state = TuiPresenterState() state.render( @@ -165,6 +170,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) state.render( @@ -172,6 +178,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) state.render( @@ -179,14 +186,16 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) - # Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..." - assert log.write.call_count == 2 + # v0.5.0: closure + worker_phase write to debug_log; transcript untouched. + assert debug_log.write.call_count == 2 + assert not log.write.called # First write = closed thinking entry containing the full accumulated text - assert "· thinking: ab" in log.write.call_args_list[0][0][0] - # Second write = worker_phase with demotion prefix - assert "· worker_phase:" in log.write.call_args_list[1][0][0] + 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 @@ -205,6 +214,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) last_update = widget.update.call_args_list[-1][0][0] @@ -228,6 +238,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) assert widget.display is True @@ -237,17 +248,20 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) assert widget.display is False - def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None: - """multiple_thinking_runs_each_get_richlog_entry [scenario]: - Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries. + 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). """ from ratatoskr.tui import TuiPresenterState log = MagicMock() + debug_log = MagicMock() widget = MagicMock() state = TuiPresenterState() state.render( @@ -255,6 +269,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) state.render( @@ -262,6 +277,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) state.render( @@ -269,46 +285,62 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) # Close the second run with a Done. state.render( - _make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True + _make_tui_done(), + log=log, + thinking_widget=widget, + tools_log=MagicMock(), + debug_log=debug_log, + raw=True, ) - # Count closed thinking entries — now dim RichText; plain text starts with "· thinking:". - thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list] + # 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. + 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]: - widget.update raises → RichLog gets BOTH a plain-labeled fallback line for - the original event AND a `[render_error] ` line - (NO exception message per INV-009 security clause); state does NOT propagate. + """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] ` + line (NO exception message per INV-009 security clause). transcript + receives nothing — routing preservation under failure (debug-shaped + event falls back to debug_log). """ from ratatoskr.tui import TuiPresenterState log = MagicMock() + debug_log = MagicMock() widget = MagicMock() widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)") state = TuiPresenterState() - # Should not raise; should write a fallback labeled line + a [render_error] line. state.render( Thinking(sse_id=SID, content="x"), log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) - writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)] - # POST-007: plain-label fallback for the original Thinking event (pre-amendment shape). + 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. 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: """state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open.""" @@ -320,6 +352,7 @@ class TestTuiPresenterState: log=MagicMock(), thinking_widget=MagicMock(), tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) s2 = TuiPresenterState() @@ -327,12 +360,14 @@ class TestTuiPresenterState: assert s2.thinking_open is False def test_cancelled_mid_thinking_closes(self) -> None: - """cancelled_mid_thinking_closes [scenario]: - Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden. + """cancelled_mid_thinking_closes [scenario, v0.5.0]: + Thinking, Cancelled → ONE closed thinking entry in debug_log + a + [cancelled] entry in transcript; widget hidden. """ from ratatoskr.tui import TuiPresenterState log = MagicMock() + debug_log = MagicMock() widget = MagicMock() state = TuiPresenterState() state.render( @@ -340,6 +375,7 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) state.render( @@ -349,12 +385,14 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) - # Closed thinking entries are now dim RichText; terminal labels are plain str. - writes = [_text_of(c[0][0]) for c in log.write.call_args_list] - assert any(w.startswith("· thinking: partial") for w in writes) - assert any(w.startswith("[cancelled]") for w in writes) + # 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] + 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: @@ -374,10 +412,16 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) state.render( - _make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False + _make_tui_done(), + log=log, + thinking_widget=widget, + tools_log=MagicMock(), + debug_log=MagicMock(), + raw=False, ) writes = [c[0][0] for c in log.write.call_args_list] # Text stream wrote "hi" with no prefix. @@ -403,33 +447,44 @@ class TestTuiPresenterState: log=log, thinking_widget=widget, tools_log=MagicMock(), + debug_log=MagicMock(), raw=True, ) state.render( - _make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True + _make_tui_done(), + log=log, + thinking_widget=widget, + tools_log=MagicMock(), + debug_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) assert not any(isinstance(w, Markdown) for w in writes) - def test_worker_phase_demoted(self) -> None: - """worker_phase_demoted [trace]: WorkerPhase → RichLog "· worker_phase:" prefix - rendered with dim Rich style (INV-003: dim style + `· ` prefix in TUI). + def test_worker_phase_demoted_to_debug_log(self) -> None: + """worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log + "· worker_phase:" prefix rendered with Australis dark-60 Rich style. + Transcript receives nothing. """ from rich.text import Text as RichText from ratatoskr.tui import TuiPresenterState log = MagicMock() + debug_log = MagicMock() state = TuiPresenterState() state.render( WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), log=log, thinking_widget=MagicMock(), tools_log=MagicMock(), + debug_log=debug_log, raw=False, ) - renderable = log.write.call_args[0][0] + # v0.5.0: WorkerPhase routes to debug_log, NOT transcript. + assert not log.write.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 # of the terminal-dim filter "dim". Assert non-empty styling either way. @@ -458,7 +513,14 @@ class TestTuiPresenterState: 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(), raw=True) + state.render( + terminal, + log=log, + thinking_widget=widget, + tools_log=MagicMock(), + debug_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__ @@ -480,6 +542,7 @@ class TestTuiPresenterState: log=log, thinking_widget=MagicMock(), tools_log=tools_log, + debug_log=MagicMock(), raw=False, ) # INV-014: write went to tools_log @@ -500,6 +563,7 @@ class TestTuiPresenterState: log=log, thinking_widget=MagicMock(), tools_log=tools_log, + debug_log=MagicMock(), raw=False, ) assert tools_log.write.called @@ -518,6 +582,7 @@ class TestTuiPresenterState: log=log, thinking_widget=MagicMock(), tools_log=tools_log, + debug_log=MagicMock(), raw=False, ) assert log.write.called @@ -536,6 +601,7 @@ class TestTuiPresenterState: log=log, thinking_widget=MagicMock(), tools_log=MagicMock(), + debug_log=MagicMock(), raw=False, ) line = log.write.call_args[0][0] @@ -553,6 +619,7 @@ class TestTuiPresenterState: log=log, thinking_widget=MagicMock(), tools_log=MagicMock(), + debug_log=MagicMock(), raw=True, ) done_line = next( @@ -580,6 +647,7 @@ class TestTuiPresenterState: log=log, thinking_widget=MagicMock(), tools_log=MagicMock(), + debug_log=MagicMock(), raw=True, ) done_line = next( @@ -738,8 +806,12 @@ class TestLayoutShape: row = app.query_one("#main-row", Horizontal) assert row is not None - async def test_left_column_has_transcript_and_prompt(self) -> None: - """left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking.""" + 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). + """ from textual.containers import Vertical from textual.widgets import Input, RichLog, Static @@ -747,14 +819,15 @@ class TestLayoutShape: async with app.run_test() as pilot: await pilot.pause() left = app.query_one("#left-column", Vertical) - assert left is not None + 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) - # Widgets are inside the left column (descendant check) assert transcript in left.walk_children() assert prompt in left.walk_children() - assert thinking 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() 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.""" @@ -819,6 +892,47 @@ class TestLayoutShape: f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}" ) + async def test_debug_tab_exists(self) -> None: + """debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog.""" + from textual.widgets import RichLog, TabPane + + app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") + async with app.run_test() as pilot: + await pilot.pause() + debug_tab = app.query_one("#debug-tab", TabPane) + debug_log = app.query_one("#debug-log", RichLog) + assert debug_log in debug_tab.walk_children() + + async def test_ctrl_2_activates_debug_tab(self) -> None: + """ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'.""" + from textual.widgets import TabbedContent + + app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("ctrl+2") + await pilot.pause() + assert app.query_one("#side-panes", TabbedContent).active == "debug-tab" + + 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. + + Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug" + and back as the operator switches via Ctrl+1 / Ctrl+2. + """ + from textual.widgets import Static + + app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") + async with app.run_test() as pilot: + await pilot.pause() + pane_name = app.query_one("#pane-name", Static) + await pilot.press("ctrl+2") + await pilot.pause() + assert str(pane_name.render()) == "Debug" + await pilot.press("ctrl+1") + await pilot.pause() + assert str(pane_name.render()) == "Tools" + import asyncio # noqa: E402 diff --git a/uv.lock b/uv.lock index 9639cdc..64da077 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.4.1" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "httpx" },