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:
+153
-39
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user