refactor(tui): content-only main pane + Debug tab + dark chrome (v0.5.0)

Two operator-driven changes off v0.4.1:

1. **Main pane is content-only.** Pre-v0.5.0 the transcript mixed
   assistant text with telemetry (Thinking closed runs, WorkerPhase,
   TextBoundary) — only tool events were factored out per #13. The
   transcript now receives ONLY: user-prompt echo, assistant Text
   deltas, [done]/[error]/[cancelled] terminal labels, and the
   post-Done Markdown render. All telemetry routes to a new Debug
   tab in the right column.

2. **Chrome no longer blue.** Textual's default Header / Footer /
   active-tab styling tints with `$primary` (Aurora blue under
   Australis), which read as garish on dark terminals. Header,
   Footer, and the TabbedContent tab strip get explicit
   `background: $surface` (Sea bright-black #373b46) so the chrome
   sits cool and unobtrusive against the Ice black background.

## Layout reshape

```
LEFT COLUMN (content only):           RIGHT COLUMN (telemetry):
  transcript (RichLog, 1fr)             thinking-current (Static, dock top)
  prompt (Input, dock bottom)           TabbedContent:
                                          Tools  (tool_start, tool_result)
                                          Debug  (thinking, worker_phase,
                                                  text_boundary)
```

The thinking-current live-preview Static moves from left → right
column so the left column is genuinely content-only. Live thinking
visibility now persists across tab switches (it docks above the
TabbedContent, not inside any tab).

## Presenter routing (TuiPresenterState.render)

Signature widens with `debug_log: RichLog`. Routing matrix:

  Text                       → log (transcript)
  Done / Error / Cancelled   → log (transcript) [terminal labels]
  ToolStart / ToolResult     → tools_log (Tools tab)
  Thinking (closed run)      → debug_log (Debug tab)
  WorkerPhase                → debug_log (Debug tab)
  TextBoundary               → debug_log (Debug tab)
  Thinking (per-delta)       → thinking_widget (live preview)

INV-009 render-exception fallback preserves routing per event class
(new INV-020) — ToolStart/Result falls back to tools_log;
Thinking/WorkerPhase/TextBoundary to debug_log; everything else to log.

## Keybindings

- Ctrl+1 → Tools tab (existing, unchanged)
- Ctrl+2 → Debug tab (NEW)

`pane-name` footer widget updates dynamically as the operator
switches tabs ("Tools" ↔ "Debug"). This was previously deferred to
"the multi-tab issue" per the Volva contract-review amendment;
multi-tab now exists, so the dynamic update lands here.

## Contract amendments

docs/contracts/issues/13.contract.md amended in-place:
- INV-015 amended: transcript is content-only; telemetry routes to
  debug_log. Old routing (telemetry in transcript) retired under the
  no-backwards-compat rule.
- INV-017 amended: thinking-current docks to right column (was left).
- INV-019 new: two TabPanes (Tools + Debug), Ctrl+1/Ctrl+2 bindings,
  dynamic pane-name update.
- INV-020 new: render-exception fallback preserves per-event-class
  routing.
- Layout-spec snapshot ASCII diagram updated.

Drift-check clean.

## Tests

239/239 GREEN (+3 new: debug_tab_exists, ctrl_2_activates_debug_tab,
pane_name_updates_on_tab_switch). 6 existing tests adjusted for the
new routing (test_thinking_closes_one_debuglog_entry,
test_multiple_thinking_runs_each_get_debuglog_entry,
test_render_exception_fallback,
test_cancelled_mid_thinking_closes,
test_worker_phase_demoted_to_debug_log,
test_left_column_content_only). ruff clean.

Live smoke against personal Worldtree: mimir KB-search turn
populated tools_log with 11 lines of tool events (search_library +
read_note); debug_log with 20 lines of worker_phase + thinking
content; transcript stayed content-only with `❯ user-prompt`
(Aurora bright-cyan) + assistant text deltas. Routing matrix
holds end-to-end. (Diagnostic note: RichLog.lines is the rendered-
output buffer; inactive TabPane content shows lines=0 until the
tab activates and renders. Internal write store is correct — this
is a Textual rendering quirk, not a routing bug.)

Minor bump (v0.4.1 → v0.5.0) per SemVer etiquette: visible routing
surface change for operators; transcript and Debug tab contents
look different from yesterday's v0.4.1.
This commit is contained in:
vh
2026-05-24 14:20:35 -07:00
parent 2756f5f1dd
commit ffd22fb587
6 changed files with 256 additions and 92 deletions
+153 -39
View File
@@ -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] <ExceptionClassName>` 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] <ExceptionClassName>`
line (NO exception message per INV-009 security clause). transcript
receives nothing — routing preservation under failure (debug-shaped
event falls back to debug_log).
"""
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