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:
@@ -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.)
|
||||
Reference in New Issue
Block a user