Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffd22fb587 |
@@ -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 (`❯ <content>`). **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.)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+75
-35
@@ -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:
|
||||
|
||||
+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