feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)

Reshape the TUI from vertical-stack single-pane to Horizontal
two-column with TabbedContent on the right; v1 has a single Tools
tab that consumes ToolStart/ToolResult SSE events previously
rendered inline in the transcript. Foundation for the rest of
design-brief §5; subsequent panes (Persona/AdminEvents/BifrostState/
ServerLog) plug in as sibling TabPanes when their substrate
blockers resolve.

Three coupled pieces, all in-place amendments to issues #4 + #12:

- **Layout**: compose() yields Horizontal#main-row containing
  Vertical#left-column (transcript + thinking-current + prompt) and
  Vertical#right-column (TabbedContent#side-panes with
  TabPane#tools-tab → RichLog#tools-log). Width split 2fr:1fr. CSS
  dock rules narrow to per-container scope so thinking-current
  toggling doesn't reflow the right column.
- **Tools pane**: TuiPresenterState.render() signature widens with
  tools_log: RichLog. ToolStart/ToolResult route there per INV-014;
  every other event keeps its issue-#12 routing. Plain-label
  fallback under render-exception preserves routing (INV-009).
- **Ctrl+1 binding + pane-name widget**: BINDINGS gains
  Binding("ctrl+1", "focus_tools") which programmatically sets
  TabbedContent.active; Textual's default preserves Input focus per
  INV-016 (test asserts; regression path documented).
  Static#pane-name in the footer renders "Tools" v1 (static — no
  tab-switch handler wiring lands in #13 per amendment-2 from
  Volva paraphrase review).

CLI mode (--send) is unaffected by design per INV-018 — non-
interactive, no tabs concept; CLI keeps inline tool-event rendering.

Contract: docs/contracts/issues/13.contract.md (drift-check clean,
two amendments applied from Volva contract-paraphrase pass).

Tests: +9 net (TestLayoutShape × 7 + TestTuiPresenterState routing
× 3, minus 1 deprecated test_tool_start_demoted superseded by
test_tool_start_routes_to_tools_log). 236 total GREEN; ruff clean.

Live smoke against personal Worldtree's mimir: tool-using turn
(KB search) populated tools_log with tool_start + tool_result for
search_library + read_note; transcript stayed chat-only with
worker_phase + thinking. Routing-not-duplication confirmed
end-to-end.
This commit is contained in:
vh
2026-05-23 21:41:16 -07:00
parent d30be12deb
commit 24e4371ec7
6 changed files with 700 additions and 204 deletions
+205
View File
@@ -0,0 +1,205 @@
---
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=<name> args=<args!r>` to `tools_log` (not `log`).
- `ToolResult` → write `· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>` 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**: 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-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
## 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 this issue lands)
```
+────────────────────────────────+──────────────────────+
| · thinking-current | ┌─ Tools ─────────┐ |
| | │ · tool_start:.. │ |
| ❯ user-typed line | │ · tool_result.. │ |
| assistant streaming text... | │ │ |
| [done] turn_id=… duration=… | │ │ |
| | │ │ |
| | │ │ |
| [prompt: type and press Enter]| └─────────────────┘ |
+────────────────────────────────+──────────────────────+
| agent · …sess_id Tools Ctrl-C twice to exit |
+───────────────────────────────────────────────────────+
```
(Width split 2fr:1fr; tab strip is Textual-default.)