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.
19 KiB
contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
| contract_version | target_module | scope | depends_on | used_by | language | complexity | estimated_loc | confidence | assumptions | open_questions | prd | dependencies | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2.1 | ratatoskr.tui | Design-brief §5 v1 entry point: reshape the TUI from vertical-stack single-pane to Horizontal two-column with `TabbedContent` on the right; first (and only v1) tab is `Tools`, which consumes `ToolStart` / `ToolResult` SSE events that previously rendered inline in the transcript. Pure in-place amendment to issue #4 + #12 — no new modules, no new files apart from this contract. The CLI (`ratatoskr.cli`) is unaffected: `--send` mode is non-interactive and keeps its current inline tool-event rendering. Substrate move only: persona / admin-events / bifrost-state / server-log panes stay deferred (blocked on remote-Worldtree topology + admin scope + opt-in flag). The TabbedContent shape makes them additive — when a blocker resolves the new pane plugs in as another TabPane sibling without further layout work. |
|
python | medium | 180 | 0.85 |
|
|
|
|
TUI layout reshape + Tools pane — §5 v1 entry point
Context
ratatoskr.tui ships v0.x as a single-pane Textual app: Header / thinking-current widget / transcript RichLog / Input / identity + hint Statics / Footer, vertical-stacked via dock: top / dock: bottom CSS (v0.2.1 layout fix). Design-brief §5 commits the product to a multi-pane debug-observability dashboard. Most of §5's panes (Persona, AdminEvents, BifrostState widget, ServerLog) are blocked on substrate that isn't here — remote-Worldtree topology blocks file-tail-based panes; issue #11's admin.events.read scope blocks the admin surfaces.
The unblocked v1 entry point is layout reshape + Tools pane together: reshape the TUI into the Horizontal two-column shape the design brief specifies, with TabbedContent on the right populated by a single Tools tab that consumes the existing ToolStart / ToolResult SSE events. No new endpoints; no scope grants; no cross-repo coordination. The shape is the foundation; subsequent panes plug in additively.
Data flow
Input (unchanged from issue #4):
args: ParsedArgs,session_id: str,agent_id: str | None,client: httpx.AsyncClient.- SSE event stream from
ratatoskr.sse_client.stream_turn.
Output (unchanged):
- Exit code via
App.exit(code).
Internal routing change:
ToolStart/ToolResultevents route totools_log: RichLog(Tools pane) instead of the main transcriptlog: RichLog.- All other event types (
Text,Thinking,WorkerPhase,TextBoundary,Done,Error,Cancelled) keep their existing routing.
Layout shape (post-amendment)
RatatoskrApp(App[int]):
compose():
yield Header()
yield Horizontal(
Vertical(
Static(id="thinking-current"), # dock: top of left column
RichLog(id="transcript"), # height: 1fr (fills middle)
Input(id="prompt"), # dock: bottom of left column
id="left-column",
),
Vertical(
TabbedContent(
TabPane("Tools", RichLog(id="tools-log"), id="tools-tab"),
# future: TabPane("Persona", …, id="persona-tab"), etc.
id="side-panes",
),
id="right-column",
),
id="main-row",
)
yield Static(id="identity") # dock: bottom of App
yield Static(id="pane-name") # dock: bottom of App (new in §5)
yield Static(id="hint") # dock: bottom of App
yield Footer()
DEFAULT_CSS reshape:
#main-row { height: 1fr; }
#left-column { width: 2fr; }
#right-column { width: 1fr; }
#thinking-current { dock: top; height: auto; }
#transcript { height: 1fr; }
#prompt { dock: bottom; }
#identity { dock: bottom; height: 1; }
#pane-name { dock: bottom; height: 1; }
#hint { dock: bottom; height: 1; }
Dock rules scope to the right container (left column for thinking-current/prompt; App for identity/pane-name/hint). The left-column prompt Input docks to the bottom of its column, not the App, so the right column's TabbedContent extends full height beside it.
Presenter routing (amendment to issue #12)
FN TuiPresenterState.render(
event: Event,
*,
log: RichLog,
thinking_widget: Static,
tools_log: RichLog, # NEW (issue #13)
raw: bool,
) -> None
Steps (only the ToolStart/ToolResult cases change; every other case keeps issue #12's behavior verbatim):
ToolStart→ write· tool_start: name=<name> args=<args!r>totools_log(notlog).ToolResult→ write· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>totools_log(notlog).- All other events → unchanged routing per issue #12 INV-005.
- Render-exception fallback (
_plain_label(event)): write totools_logif the event isToolStart/ToolResult; write tologotherwise. Routing preservation under failure.
Keybindings (amendment)
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), # NEW
]
def action_focus_tools(self) -> None:
self.query_one(TabbedContent).active = "tools-tab"
# Input focus is preserved by Textual's default behavior — TabbedContent
# doesn't steal focus when `.active` is set programmatically.
Ctrl+1 is the v1 entry of the design-brief Ctrl+1..5 family. Ctrl+2..5 get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts Input keeps focus across the tab switch.
Status footer (amendment)
New Static(id="pane-name") widget alongside the existing identity + hint widgets. v1 renders the literal string "Tools" set once at on_mount; the widget never updates after that. Dynamic updating + tab-switch handler wiring is out of scope for #13 — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a @on(TabbedContent.TabActivated) handler in this issue is out of spec.
Invariants
- INV-013: Layout is
Horizontaltwo-column. Left column width = 2fr; right column width = 1fr. - INV-014:
ToolStart/ToolResultevents route totools_log(Tools pane), never tolog(transcript). - INV-015 (amended v0.5.0): The transcript (
log) is content-only — receivesText,Done(label + Markdown body + Rule),Error,Cancelled, and the user-prompt echo (❯ <content>). All telemetry events (Thinkingclosed runs,WorkerPhase,TextBoundary) route todebug_log(Debug pane), NOTlog. Live thinking deltas continue to updatethinking_widgetper-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+2tab switches. - INV-017 (amended v0.5.0):
thinking-currentStatic docks to the top of the right column (aboveTabbedContent), 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(idtools-tab, contains#tools-log) +Debug(iddebug-tab, contains#debug-log). Ctrl+1 activates Tools; Ctrl+2 activates Debug.pane-nameStatic reflects the active tab name dynamically. - INV-020 (new v0.5.0): Render-exception fallback (INV-009) preserves routing per event class:
ToolStart/ToolResultfallback writes totools_log;WorkerPhase/Thinking/TextBoundaryfallback writes todebug_log; everything else falls back tolog.
TESTS (additions / changes to test_tui.py)
- test_compose_has_horizontal_main_row: RatatoskrApp.compose() yields a Horizontal with id="main-row" containing left-column + right-column children.
- test_compose_right_column_has_tabbed_content: query_one("#side-panes", TabbedContent) is non-None; one TabPane child with title="Tools" id="tools-tab".
- test_compose_left_column_has_transcript_input: query_one("#left-column", Vertical) contains #transcript (RichLog) + #prompt (Input).
- test_tools_log_present: query_one("#tools-log", RichLog) is non-None; lives inside the tools-tab TabPane.
- test_pane_name_widget_renders_tools: query_one("#pane-name", Static).renderable == "Tools" (v1 static).
- test_tool_start_routes_to_tools_log: stream_turn emits ToolStart → tools_log receives the line; transcript RichLog does NOT receive it.
- test_tool_result_routes_to_tools_log: stream_turn emits ToolResult → tools_log receives the line; transcript does NOT receive it.
- test_text_event_still_routes_to_transcript: stream_turn emits Text("hello") → transcript receives it; tools_log does NOT.
- test_thinking_event_still_routes_to_thinking_widget: thinking deltas continue to update #thinking-current Static, not tools_log.
- test_done_event_still_routes_to_transcript: Done event renders `[done] …` in transcript, not tools_log.
- test_ctrl_1_activates_tools_tab: simulate Ctrl+1 → TabbedContent.active == "tools-tab".
- test_ctrl_1_preserves_input_focus: simulate Ctrl+1 while Input is focused → Input is still focused afterwards.
- test_plain_label_fallback_routes_tool_events_to_tools_log: simulate render exception on a ToolStart → tools_log gets the _plain_label fallback string; transcript doesn't.
Existing tests that need adjustment (NOT rewrite):
- Any test that asserted
log.write(...)was called with atool_start: …/tool_result: …string changes its target widget totools_loginstead. - TestAppMount tests gain
tools_logwidget lookup assertions.
ERROR_ROUTING (unchanged)
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 v0.5.0)
+────────────────────────────────+──────────────────────+
| ❯ 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 |
+───────────────────────────────────────────────────────+
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.)