--- contract_version: "2.1" target_module: "ratatoskr.tui" scope: "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." depends_on: - "textual" used_by: [] language: "python" complexity: "medium" estimated_loc: 180 confidence: 0.85 assumptions: - "Textual's `TabbedContent(*titles, initial='')` + `TabPane(title, *children, id=...)` is the right primitive for the right-column tabs (verified API at textual.widgets._tabbed_content). One `TabPane(\"Tools\", tools_log, id=\"tools-tab\")` in v1; additional siblings get appended as Persona/AdminEvents/BifrostState/ServerLog land." - "Textual's `Horizontal` + `Vertical` containers compose the two-column split (verified at textual.containers). Width via CSS `width: 2fr` on the left container + `width: 1fr` on the right container gives the 2:1 chat-primary split." - "**The presenter contract amendment is small and tightly scoped**: `TuiPresenterState.render` gains a `tools_log: RichLog` parameter alongside the existing `log: RichLog` (main transcript) + `thinking_widget: Static`. `ToolStart` / `ToolResult` events route to `tools_log`; every other event (Text, Thinking, WorkerPhase, Done, Error, Cancelled, TextBoundary) keeps its existing routing to `log` + `thinking_widget`. The plain-label fallback path in `_plain_label` (issue #12 INV-009 render-exception recovery) keeps its current shape — only the routing target changes." - "**Tool events are routed, not duplicated**. The brief's §5 wording 'side pane (inline-from-SSE for v1)' factors tool events OUT of the main transcript. A consumer who wants to debug a tool-using turn now reads the Tools pane; the main transcript stays focused on assistant text. Trade-off: a fast-skim of the transcript no longer shows tool activity inline; if that hurts the debug ergonomics empirically, a follow-up issue can add a one-line `· tool_used name=...` breadcrumb to the transcript as a compromise. v1 commits to the cleaner split." - "**Demoted-prefix style stays consistent across panes**. ToolStart in the Tools pane renders as `· tool_start: name=foo args={...}` — the same `· ` ASCII prefix issue #12 INV-005 established for demoted telemetry in the main transcript. Pane separation handles the visual hierarchy; prefix style stays cross-pane consistent so the operator's mental model is portable." - "**Input field retains focus across tab switches** (design-brief §5 invariant: 'Tab key (Ctrl+1..5) jumps between tabs without losing focus on the input field'). INV-016 is the load-bearing invariant; the assumption about Textual's default behavior is just an implementation hint. If Textual's default `TabbedContent.active = ...` programmatic assignment preserves Input focus (current observed behavior), no extra code is needed. If a future Textual version regresses on this, the implementation MUST add `self.query_one('#prompt', Input).focus()` immediately after the `.active = ` assignment in `action_focus_tools` to satisfy INV-016. The test `test_ctrl_1_preserves_input_focus` is the regression guard; if it fails, the fix is the explicit `.focus()` call, not relaxing the invariant. Pre-existing INV-007 (Input always-focused except during error sub-states) is preserved verbatim." - "**Status footer gains a `current-pane-name` element** — design-brief §5 calls for it explicitly. v1 only has one tab so the indicator stable-renders \"Tools\". Wiring it as a separate Static (`id='pane-name'`) docked alongside identity + hint makes it trivially extend when more tabs land — the widget is in place; the value will become dynamic in the future multi-tab issue. **No tab-switch handler wiring lands in #13.** The `on_mount` flow populates `pane-name` once with the literal string \"Tools\" and never updates it. Adding event-handler plumbing in v1 (a `@on(TabbedContent.TabActivated)` handler, etc.) is out of scope — that's a deliberate deferral, not an implementer's call." - "**The existing `· thinking-current` widget keeps its position** — docked to the top of the left column (was docked top of the whole App; now docked top of the left Vertical container). Pre-amendment dock-fix from v0.2.1 stays; the scope of the dock just narrows from \"App\" to \"left column\" so it doesn't bleed into the right column's TabbedContent area." - "**Width split is fixed `2fr : 1fr` for v1**. User-resizable splits are textual-native (via `Splitter` or similar), but adding interactive resize is its own UX surface. v1 ships fixed; if the right pane proves cramped on narrow terminals operators will tell us. Out of scope." - "**TabbedContent's CSS classes**: the right column's TabbedContent + its tabbed-content wrappers (`#tabbed-content`, `.--tabs`, etc.) come with Textual's default styling. No custom CSS for tab strip in v1; if the visual feels wrong adjust later. The contract specifies the structure; the chrome stays Textual-default." - "**Test strategy**: existing TestStreamTurnWorker tests for Text/Thinking/Done/Error/Cancelled routing stay GREEN unchanged (they assert what shows in the transcript log; that still shows the same content). ToolStart/ToolResult tests get adjusted to assert routing to `tools_log` instead of `log`. New tests cover layout shape (Horizontal parent exists, TabbedContent with tools-tab on the right) + Ctrl+1 binding + tools_log writes for tool events." - "**Issue #12's INV-009 render-exception fallback path** stays correct: `_plain_label(event)` is still callable; the render method's except branch writes the labeled-string to the appropriate Widget (tools_log for tool events; log for everything else). The fallback writes to the same routed widget as a successful render — failure mode preserves the routing invariant." open_questions: - "Should the Tools tab show a count badge when new tool events arrive while the user is on a future Persona/AdminEvents tab? Draft: no for v1 — only one tab, so the question is moot. When persona/admin-events panes land, revisit: a small `[N]` badge on the tab header (`'Tools [3]'`) would help operators not miss tool activity that happens off-screen. Defer to a follow-up that touches multiple panes." - "Should the Tools pane support filter-by-tool-name (e.g., show only `kb_search` results)? Draft: no for v1 — flat scroll matches the design brief's posture. The volume of tool events per turn is small enough that scrollback handles the use case. Revisit if mimir-style heavy-tool agents produce visible-cluttering volume." - "Should the v1 Ctrl+1 binding be Ctrl+1 specifically, or `t` for 'tools' (no modifier)? Draft: Ctrl+1 — design brief specifies `Ctrl+1..5` as the family; matches the Ctrl-prefix discipline already used by Ctrl-C / Ctrl-D bindings. Plain-letter bindings would steal letter input from the Input field; Ctrl-prefixed is the standard escape." prd: issue: 13 issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/13" body_sha256_16: "52c8f886a9cc986a" lock_in_comment_id: null lock_in_sha256_16: null lock_in_at: null pinned_at: "2026-05-24T02:28:28+00:00" dependencies: - issue: 4 path: "src/ratatoskr/tui.py" reason: "In-place contract amendment: `RatatoskrApp.compose()` reshapes from vertical-stack to Horizontal 2-col + TabbedContent right; `RatatoskrApp.DEFAULT_CSS` reshapes to scope dock rules to the new left/right containers; `RatatoskrApp.__init__` is unchanged (state attributes carry over); `RatatoskrApp.on_mount` gains pane-name widget population + tools_log lookup; `RatatoskrApp.BINDINGS` gains `Ctrl+1` → `action_focus_tools`; existing `action_interrupt` / `action_quit` unchanged." - issue: 12 path: "src/ratatoskr/tui.py" reason: "In-place contract amendment: `TuiPresenterState.render` signature widens to accept `tools_log: RichLog` alongside `log` + `thinking_widget`; ToolStart/ToolResult branches write to `tools_log` instead of `log`. `_stream_turn_worker` does the new lookup (`self.query_one('#tools-log', RichLog)`) and threads it through. All other event branches stay verbatim." --- # 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` / `ToolResult` events route to `tools_log: RichLog` (Tools pane) instead of the main transcript `log: 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:** ```css #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= args=` to `tools_log` (not `log`). - `ToolResult` → write `· tool_result: name= duration_ms= result=` to `tools_log` (not `log`). - All other events → unchanged routing per issue #12 INV-005. - Render-exception fallback (`_plain_label(event)`): write to `tools_log` if the event is `ToolStart`/`ToolResult`; write to `log` otherwise. 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 `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** *(amended v0.5.0)*: The transcript (`log`) is **content-only** — receives `Text`, `Done` (label + Markdown body + Rule), `Error`, `Cancelled`, and the user-prompt echo (`❯ `). **All telemetry events (`Thinking` closed runs, `WorkerPhase`, `TextBoundary`) route to `debug_log` (Debug pane), NOT `log`.** Live thinking deltas continue to update `thinking_widget` per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule. - **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches. - **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only. - **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005. - **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically. - **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_log`; everything else → `log`. - **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired. - **INV-022** *(amended v0.7.1)*: Thinking deltas COALESCE on `\n` boundaries before writing to `thinking_log`. The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`; whenever the buffer contains `\n`, the leading line(s) flush as RichLog entries (one entry per natural paragraph). The run closes on the next non-thinking event: any tail in the buffer flushes as a final line, then `Rule(title=f"turn N · thinking #K end")`. Pre-v0.7.1 per-delta-per-line caused token-spam (Worldtree emits thinking at token granularity); coalescing produces one log line per natural paragraph, not per token. - **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging. - **INV-024** *(amended v0.6.5)*: `thinking-current` Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into `thinking_log` (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator. ## 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 a `tool_start: …` / `tool_result: …` string changes its target widget to `tools_log` instead. - TestAppMount tests gain `tools_log` widget 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.)