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:
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.)
+25 -10
View File
@@ -32,17 +32,28 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-24 (post-v0.3.0 startup agent picker):_
_As of 2026-05-24 (post-v0.4.0 §5 entry point: layout reshape +
Tools pane):_
**Status: v0.3.0 shipped.** Eight core issues complete (`sse_client`
**Status: v0.4.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) + robustness fix #7 (MalformedSseData
+ empty-skip) + v0.2.1 TUI layout fix. 227/227 tests GREEN; ruff
clean.
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
+ robustness fix #7 (MalformedSseData + empty-skip) + v0.2.1 TUI
layout fix. 236/236 tests GREEN; ruff clean.
**§5 v1 entry point shipped (issue #13).** TUI now Horizontal
two-column: left = chat surface (transcript + thinking-current +
prompt); right = TabbedContent with single Tools tab (RichLog
receiving ToolStart/ToolResult events). Routing-not-duplication:
tool events leave the main transcript entirely. Ctrl+1 activates
Tools tab without losing Input focus (INV-016). New `pane-name`
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.3.0 feat(sessions,cli,tui): issue #8startup agent picker
- v0.4.0 feat(tui): issue #13§5 layout reshape + Tools pane
- `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)
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun
@@ -64,10 +75,14 @@ Last commits on `main`:
"a lot better" interactively.
**Outstanding operator-side todos:**
- **Interactive TUI picker eyeball** — `source env.sh && uv run
ratatoskr --new` (no flags after) should show the picker; pick
lofn; type a message; verify response streams cleanly. Auto-pick
smoke confirmed the wiring; visual confirmation pending.
- **Interactive §5 layout eyeball** — `source env.sh && uv run
ratatoskr --new --agent mimir`, ask a tool-using question
("search your KB for X"). Confirm: left column shows chat /
thinking; right column's Tools tab shows tool_start +
tool_result with `· ` prefix; Ctrl+1 doesn't break input focus;
no width-clamp issues on the operator's terminal. Programmatic
smoke confirmed all the routing + binding; visual confirmation
pending.
- **Post-v0.2.1 TUI multi-turn eyeball** — confirm thinking-run
bouncing is gone across multiple turns; the layout fix has only
been confirmed for a single turn so far.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.3.0"
version = "0.4.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+94 -30
View File
@@ -16,7 +16,19 @@ from typing import ClassVar, Literal
import httpx
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, RichLog, Static
from textual.containers import Horizontal, Vertical
from textual.widgets import (
Footer,
Header,
Input,
Label,
ListItem,
ListView,
RichLog,
Static,
TabbedContent,
TabPane,
)
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
from ratatoskr.sessions import (
@@ -114,12 +126,18 @@ class TuiPresenterState:
*,
log: RichLog,
thinking_widget: Static,
tools_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.
Exceptions are caught at the presenter boundary (INV-009 fallback).
"""
assert isinstance(
@@ -194,12 +212,14 @@ class TuiPresenterState:
))
return
if isinstance(event, ToolStart):
log.write(_dim(
# Issue #13 INV-014: tool events route to the Tools pane.
tools_log.write(_dim(
f"· tool_start: name={event.name} args={event.arguments!r}"
))
return
if isinstance(event, ToolResult):
log.write(_dim(
# Issue #13 INV-014: tool events route to the Tools pane.
tools_log.write(_dim(
f"· tool_result: name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}"
))
@@ -213,8 +233,13 @@ class TuiPresenterState:
# INV-009 + POST-007 fallback: write pre-amendment plain-label line for
# the original event AND a render_error line with the class name only
# (NO exception message — security clause). Volva F1 fix.
log.write(_plain_label(event))
log.write(f"[render_error] {type(exc).__name__}")
#
# 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
target.write(_plain_label(event))
target.write(f"[render_error] {type(exc).__name__}")
class AgentPickerApp(App[str | None]):
@@ -281,13 +306,24 @@ class AgentPickerApp(App[str | None]):
class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane."""
# Issue #12 follow-up: anchor layout so Input never moves.
# Pre-fix: every widget was auto-stacked. RichLog grew with content,
# thinking-current toggled display 0↔N rows per thinking-run — both pushed
# Input around mid-turn. Fix: dock the chrome to the top/bottom edges and
# let RichLog (the only `1fr` widget) absorb all layout reflows internally
# via its scroll viewport, so screen-relative positions stay stable.
# 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.
#
# 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.
DEFAULT_CSS = """
#main-row {
height: 1fr;
}
#left-column {
width: 2fr;
}
#right-column {
width: 1fr;
}
#thinking-current {
dock: top;
height: auto;
@@ -302,6 +338,10 @@ class RatatoskrApp(App[int]):
dock: bottom;
height: 1;
}
#pane-name {
dock: bottom;
height: 1;
}
#hint {
dock: bottom;
height: 1;
@@ -311,6 +351,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.
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
]
HINT_IDLE = "Ctrl-C twice to exit"
@@ -337,25 +381,29 @@ class RatatoskrApp(App[int]):
def compose(self) -> ComposeResult:
yield Header()
# Issue #12 follow-up: thinking-current sits at the TOP under Header (via
# DEFAULT_CSS `dock: top`). Pre-fix it lived between hint and Footer in
# the auto-stacked flow, so its display=True/False toggle per
# thinking-run pushed Input + identity + hint up/down on every cycle.
# Docking top + RichLog filling middle stabilises Input's screen
# position; thinking-current grows/shrinks under Header where the
# reflow doesn't affect anything else.
yield Static("", id="thinking-current")
# markup=False so labeled lines like "[cancel_failed] ..." render verbatim
# (Rich would otherwise interpret square-bracket spans as style markup and
# strip them). The post-Done markdown render uses Markdown() directly which
# is a Rich Renderable and renders correctly without widget-level markup=True.
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
yield Input(id="prompt", placeholder="Type a message and press Enter")
# INV-002 + INV-003: visible identity + hint widgets (Footer-area rendering).
# Textual's built-in Footer renders BINDINGS descriptions; these Static widgets
# carry the session-identity and Ctrl-C-state strings the contract requires be
# always-visible.
# 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).
# 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
# which is a Rich Renderable and renders correctly without
# 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"):
with TabbedContent(id="side-panes"):
with TabPane("Tools", id="tools-tab"):
yield RichLog(
id="tools-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.
yield Static("", id="identity")
yield Static("Tools", id="pane-name")
yield Static(self.HINT_IDLE, id="hint")
yield Footer()
@@ -411,13 +459,19 @@ 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.
tools_log = self.query_one("#tools-log", RichLog)
presenter = TuiPresenterState()
try:
async for event in stream_turn(self.client, self.session_id, content):
if self.active_turn_id is None:
self.active_turn_id = event.sse_id.turn_id
presenter.render(
event, log=log, thinking_widget=thinking_widget, raw=self.args.raw
event,
log=log,
thinking_widget=thinking_widget,
tools_log=tools_log,
raw=self.args.raw,
)
if isinstance(event, (Done, Error, Cancelled)):
break
@@ -468,6 +522,16 @@ class RatatoskrApp(App[int]):
self.stream_worker.cancel()
self.exit(0)
def action_focus_tools(self) -> None:
"""Issue #13: Ctrl+1 activates the Tools tab. INV-016 preserves Input focus.
Current Textual behavior preserves Input focus when TabbedContent.active is
set programmatically. If a future Textual regresses on that, add an
explicit `self.query_one('#prompt', Input).focus()` after the assignment
— `test_ctrl_1_preserves_input_focus` is the regression guard.
"""
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
def run_tui(args: ParsedArgs) -> int:
"""Sync entry point — delegates to the async resolve-then-run flow.
+374 -162
View File
@@ -15,6 +15,7 @@ from ratatoskr.sse_client import (
SseId,
Text,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
@@ -120,9 +121,27 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Thinking(sse_id=SID, content="a"), log=log, thinking_widget=widget, raw=False)
state.render(Thinking(sse_id=SID, content="b"), log=log, thinking_widget=widget, raw=False)
state.render(Thinking(sse_id=SID, content="c"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Widget updated 3 times — once per delta — with cumulative content
assert widget.update.call_count == 3
# Latest call shows the full accumulated content (under 200 chars so no truncation)
@@ -141,12 +160,25 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Thinking(sse_id=SID, content="a"), log=log, thinking_widget=widget, raw=False)
state.render(Thinking(sse_id=SID, content="b"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
@@ -168,7 +200,13 @@ class TestTuiPresenterState:
state = TuiPresenterState()
# Push 500 chars across multiple deltas.
long = "x" * 500
state.render(Thinking(sse_id=SID, content=long), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content=long),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars
assert last_update.startswith("")
@@ -185,13 +223,20 @@ class TestTuiPresenterState:
widget.display = False # initial state (composed hidden)
state = TuiPresenterState()
# First thinking delta → widget visible
state.render(Thinking(sse_id=SID, content="x"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
assert widget.display is True
# Closure (WorkerPhase) → widget hidden
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
assert widget.display is False
@@ -206,14 +251,30 @@ class TestTuiPresenterState:
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="first"), log=log, thinking_widget=widget, raw=False
Thinking(sse_id=SID, content="first"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="second"), log=log, thinking_widget=widget, raw=False
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="second"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Close the second run with a Done.
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=True)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), 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]
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
@@ -234,7 +295,13 @@ class TestTuiPresenterState:
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, raw=False)
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
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).
assert any(w.startswith("[thinking]") for w in writes), writes
@@ -252,6 +319,7 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="x"),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
s2 = TuiPresenterState()
@@ -271,6 +339,7 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="partial"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
@@ -279,6 +348,7 @@ class TestTuiPresenterState:
),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Closed thinking entries are now dim RichText; terminal labels are plain str.
@@ -299,8 +369,16 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=False)
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=False)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False
)
writes = [c[0][0] for c in log.write.call_args_list]
# Text stream wrote "hi" with no prefix.
assert "hi" in writes
@@ -320,8 +398,16 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=True)
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=True)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=True,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_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)
@@ -340,6 +426,7 @@ class TestTuiPresenterState:
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
renderable = log.write.call_args[0][0]
@@ -369,25 +456,72 @@ 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, raw=True)
state.render(terminal, log=log, thinking_widget=widget, tools_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__
def test_tool_start_demoted(self) -> None:
"""tool_start_demoted [trace]: ToolStart → RichLog line starts with "· tool_start:" """
def test_tool_start_routes_to_tools_log(self) -> None:
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
Issue #13: tool events route to the dedicated Tools pane (right column).
Pre-#13 wrote them to the main transcript with `· tool_start:` prefix.
Post-#13 the prefix is preserved but the destination shifts.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
# Demoted telemetry is wrapped in dim RichText; check plain content.
assert _text_of(log.write.call_args[0][0]).startswith("· tool_start:")
# INV-014: write went to tools_log
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
# INV-014: transcript was NOT written to
assert not log.write.called
def test_tool_result_routes_to_tools_log(self) -> None:
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called
def test_text_event_does_not_route_to_tools_log(self) -> None:
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
assert log.write.called
assert log.write.call_args[0][0] == "hello"
# INV-015: tools_log was NOT written to
assert not tools_log.write.called
def test_text_no_prefix(self) -> None:
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
@@ -396,7 +530,11 @@ class TestTuiPresenterState:
log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"), log=log, thinking_widget=MagicMock(), raw=False
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
line = log.write.call_args[0][0]
# Pure content, no demotion prefix.
@@ -409,10 +547,15 @@ class TestTuiPresenterState:
log = MagicMock()
state = TuiPresenterState()
state.render(
_make_tui_done(duration_ms=5467), log=log, thinking_widget=MagicMock(), raw=True
_make_tui_done(duration_ms=5467),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0] for c in log.write.call_args_list
c[0][0]
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
)
assert "duration=5.5s" in done_line
@@ -431,10 +574,15 @@ class TestTuiPresenterState:
"cached_input_tokens": 0,
}
state.render(
_make_tui_done(usage=usage), log=log, thinking_widget=MagicMock(), raw=True
_make_tui_done(usage=usage),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0] for c in log.write.call_args_list
c[0][0]
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -456,16 +604,16 @@ def _text_of(write_arg: object) -> str:
return "" # Markdown / Rule / etc. — not text content
def _make_tui_done(
*, duration_ms: int = 1, usage: dict[str, int] | None = None
) -> Done:
def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
return Done(
sse_id=SID,
phase="succeeded",
response="r",
model="m",
duration_ms=duration_ms,
usage=usage if usage is not None else {
usage=usage
if usage is not None
else {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
@@ -572,6 +720,104 @@ class TestAppMount:
assert app.session_id[-8:] in rendered
# Issue #13 — TUI layout reshape + Tools pane (§5 v1 entry point)
class TestLayoutShape:
"""INV-013 + INV-014 + INV-017: Horizontal two-column layout with Tools tab."""
async def test_main_row_is_horizontal(self) -> None:
"""main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row."""
from textual.containers import Horizontal
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
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."""
from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
left = app.query_one("#left-column", Vertical)
assert left is not None
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()
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."""
from textual.widgets import TabbedContent, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
tabbed = app.query_one("#side-panes", TabbedContent)
assert tabbed is not None
tools_tab = app.query_one("#tools-tab", TabPane)
assert tools_tab is not None
async def test_tools_log_inside_tools_tab(self) -> None:
"""tools_log_inside_tools_tab: tools-log RichLog is a descendant of tools-tab TabPane."""
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()
tools_tab = app.query_one("#tools-tab", TabPane)
tools_log = app.query_one("#tools-log", RichLog)
assert tools_log in tools_tab.walk_children()
async def test_pane_name_widget_renders_tools(self) -> None:
"""pane_name_widget_renders_tools [INV-pane-name]: #pane-name == 'Tools' on first frame."""
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)
rendered = str(pane_name.render())
assert rendered == "Tools"
async def test_ctrl_1_activates_tools_tab(self) -> None:
"""ctrl_1_activates_tools_tab [tracer]: Ctrl+1 → TabbedContent.active == 'tools-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+1")
await pilot.pause()
tabbed = app.query_one("#side-panes", TabbedContent)
assert tabbed.active == "tools-tab"
async def test_ctrl_1_preserves_input_focus(self) -> None:
"""ctrl_1_preserves_input_focus [INV-016]: Ctrl+1 does NOT steal focus from Input."""
from textual.widgets import Input
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt", Input)
prompt.focus()
await pilot.pause()
assert app.focused is prompt
await pilot.press("ctrl+1")
await pilot.pause()
assert app.focused is prompt, (
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
)
import asyncio # noqa: E402
from textual.widgets import Input # noqa: E402
@@ -584,9 +830,7 @@ async def _noop_worker(self, content: str) -> None:
class TestOnInputSubmitted:
@respx.mock
async def test_happy_submit_echoes_and_spawns(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_happy_submit_echoes_and_spawns(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_submit_echoes_and_spawns [happy,tracer]: …"""
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
writes = _spy_writes(monkeypatch)
@@ -603,9 +847,7 @@ class TestOnInputSubmitted:
assert app.stream_worker is not None
@respx.mock
async def test_empty_submit_no_op(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_empty_submit_no_op(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned."""
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
app = _resolved_app(_args_existing())
@@ -673,9 +915,7 @@ class TestOnInputSubmitted:
assert app.stream_worker is None
@respx.mock
async def test_footer_hint_flips_to_cancel(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_footer_hint_flips_to_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""footer_hint_flips_to_cancel [trace]: hint widget shows 'Ctrl-C to cancel'."""
from textual.widgets import Static
@@ -743,22 +983,14 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
class TestStreamTurnWorker:
@respx.mock
async def test_happy_text_done_renders_markdown(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_renders_markdown [happy,tracer]: …"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "hello"})
+ _sse_chunk("42:2", _DONE_BODY)
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
"42:2", _DONE_BODY
)
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(stream)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
@@ -774,26 +1006,19 @@ class TestStreamTurnWorker:
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
from rich.markdown import Markdown
from rich.rule import Rule
assert any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_raw_flag_skips_markdown_render(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""raw_flag_skips_markdown_render [trace]: …"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "hi"})
+ _sse_chunk("42:2", _DONE_BODY)
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
"42:2", _DONE_BODY
)
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(stream)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing(raw=True))
async with app.run_test() as pilot:
@@ -802,19 +1027,14 @@ class TestStreamTurnWorker:
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
from rich.markdown import Markdown
from rich.rule import Rule
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_error_terminal_returns_to_idle(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""error_terminal_returns_to_idle [happy]: …"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "x"})
+ _sse_chunk(
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
"42:2",
{
"type": "error",
@@ -823,13 +1043,9 @@ class TestStreamTurnWorker:
"message": "boom",
},
)
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(stream)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
@@ -843,18 +1059,12 @@ class TestStreamTurnWorker:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""cancelled_terminal_returns_to_idle [happy]: …"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "x"})
+ _sse_chunk("42:2", _CANCELLED_STREAM_BODY)
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
"42:2", _CANCELLED_STREAM_BODY
)
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(stream)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
@@ -864,9 +1074,7 @@ class TestStreamTurnWorker:
assert any("[cancelled]" in str(w) for w in writes)
@respx.mock
async def test_active_turn_id_set_on_first_event(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_active_turn_id_set_on_first_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""active_turn_id_set_on_first_event [trace]: …"""
# Use a gated stream: yield first event, then hold, so we can inspect mid-stream
first = _sse_chunk("42:1", {"type": "text", "content": "x"})
@@ -952,12 +1160,11 @@ class TestStreamTurnWorker:
) -> None:
"""malformed_sse_data_returns_to_idle [error]: bad-JSON → [malformed_sse_data]; idle."""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "x"})
+ b"id: 42:2\ndata: not-json\n\n"
_sse_chunk("42:1", {"type": "text", "content": "x"}) + b"id: 42:2\ndata: not-json\n\n"
)
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(stream)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
@@ -970,20 +1177,16 @@ class TestStreamTurnWorker:
assert app.return_value is None
@respx.mock
async def test_rendered_event_per_event(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_rendered_event_per_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""rendered_event_per_event [trace]: …"""
chunks = (
_sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42})
+ _sse_chunk("42:2", {"type": "text", "content": "hi"})
+ _sse_chunk("42:3", _DONE_BODY)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(chunks))
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(chunks)
)
# Per issue #12: rendering went from stateless _render_event_to_log to
# TuiPresenterState.render; the spy moves to the new method.
@@ -1018,9 +1221,7 @@ class TestActionInterrupt:
assert app.return_value == 0
@respx.mock
async def test_streaming_first_ctrl_c_cancels(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_streaming_first_ctrl_c_cancels(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""streaming_first_ctrl_c_cancels [scenario,tracer]: …"""
# Stream that yields one text event (sets active_turn_id) then waits forever
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
@@ -1077,9 +1278,7 @@ class TestActionInterrupt:
gate.set()
@respx.mock
async def test_streaming_no_turn_id_force_exits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_streaming_no_turn_id_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""streaming_no_turn_id_force_exits [scenario]: …"""
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/0/cancel").mock(
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
@@ -1222,9 +1421,7 @@ class TestActionQuit:
assert app.return_value == 0
@respx.mock
async def test_streaming_ctrl_d_force_exits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_streaming_ctrl_d_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""streaming_ctrl_d_force_exits [scenario]: …"""
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
@@ -1315,9 +1512,7 @@ class TestResolveThenRun:
assert snapshot["client_open"] is True
@respx.mock
def test_happy_new_with_end_user_id_resolve(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_happy_new_with_end_user_id_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST body.
Issue #5 amends #4: _resolve_then_run's create_session call now forwards
@@ -1363,9 +1558,7 @@ class TestResolveThenRun:
assert "vh@phasefinal.com" in ua
@respx.mock
def test_alt_screen_never_opens_on_resolve_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_alt_screen_never_opens_on_resolve_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""alt_screen_never_opens_on_resolve_error [trace]: 404 → run_tui=12; run_async unhit.
Directly probes INV-001: session resolution failures MUST short-circuit
@@ -1387,9 +1580,7 @@ class TestResolveThenRun:
assert not sentinel_called
@respx.mock
def test_agent_not_found_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
def test_agent_not_found_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None:
"""agent_not_found_on_resolve [error]: --new + 404 → stderr [agent_not_found]; exit 12."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
@@ -1401,9 +1592,7 @@ class TestResolveThenRun:
assert "agent_id=mimir" in err
@respx.mock
def test_session_api_failed_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
def test_session_api_failed_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None:
"""session_api_failed_on_resolve [error]: --new + 500 → [session_api_failed] stderr."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(500, content=b"server error")
@@ -1415,9 +1604,7 @@ class TestResolveThenRun:
assert "status=500" in err
@respx.mock
def test_network_error_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
def test_network_error_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None:
"""network_error_on_resolve [error]: --new + ConnectError → [network_error] stderr."""
respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down"))
rc = run_tui(_args_new())
@@ -1427,9 +1614,7 @@ class TestResolveThenRun:
assert "ConnectError" in err
@respx.mock
def test_stderr_label_format_matches_cli(
self, capsys: pytest.CaptureFixture[str]
) -> None:
def test_stderr_label_format_matches_cli(self, capsys: pytest.CaptureFixture[str]) -> None:
"""stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce
identical stderr lines for AgentNotFound (INV-006).
"""
@@ -1501,9 +1686,7 @@ class TestResolveThenRun:
assert snapshot["closed_during_run"] is False
assert client.is_closed is True
def test_run_tui_closes_client_on_app_exit(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_run_tui_closes_client_on_app_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""run_tui_closes_client_on_app_exit: async-with closes client after app.run_async ret."""
seen_clients: list[httpx.AsyncClient] = []
@@ -1621,14 +1804,24 @@ class TestAgentPickerApp:
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
agent_id="a",
name="A",
description="x",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
AgentInfo(
agent_id="b", name="B", description="y",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
agent_id="b",
name="B",
description="y",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
]
app = AgentPickerApp(agents)
@@ -1641,6 +1834,7 @@ class TestAgentPickerApp:
app.exit(None)
import asyncio
asyncio.run(probe())
def test_picker_pick_returns_agent_id(self) -> None:
@@ -1650,14 +1844,24 @@ class TestAgentPickerApp:
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
agent_id="a",
name="A",
description="x",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
AgentInfo(
agent_id="b", name="B", description="y",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
agent_id="b",
name="B",
description="y",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
]
app = AgentPickerApp(agents)
@@ -1674,6 +1878,7 @@ class TestAgentPickerApp:
return app.return_value
import asyncio
chosen = asyncio.run(drive())
assert chosen == "b"
@@ -1684,9 +1889,14 @@ class TestAgentPickerApp:
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
agent_id="a",
name="A",
description="x",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
]
app = AgentPickerApp(agents)
@@ -1698,6 +1908,7 @@ class TestAgentPickerApp:
return app.return_value
import asyncio
chosen = asyncio.run(drive())
assert chosen is None
@@ -1742,11 +1953,13 @@ class TestResolveThenRunWithPicker:
monkeypatch.setattr(RatatoskrApp, "run_async", capture_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 0
assert agents_route.call_count == 1
assert sessions_route.call_count == 1
import json as _json
body = _json.loads(sessions_route.calls[0].request.content)
assert body == {"agent_id": "lofn"}
assert snapshot["agent_id"] == "lofn"
@@ -1777,6 +1990,7 @@ class TestResolveThenRunWithPicker:
monkeypatch.setattr(RatatoskrApp, "run_async", sentinel)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 0
assert agents_route.call_count == 1
@@ -1784,9 +1998,7 @@ class TestResolveThenRunWithPicker:
assert main_called is False
@respx.mock
def test_picker_skipped_when_agent_id_provided(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_picker_skipped_when_agent_id_provided(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""picker_skipped_when_agent_id_provided: --new --agent mimir → list_agents NOT called."""
agents_route = respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
@@ -1800,15 +2012,14 @@ class TestResolveThenRunWithPicker:
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new()) # agent_id="mimir"
assert rc == 0
assert agents_route.call_count == 0
assert sessions_route.call_count == 1
@respx.mock
def test_picker_skipped_when_session_mode(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""
agents_route = respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
@@ -1822,6 +2033,7 @@ class TestResolveThenRunWithPicker:
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_existing())
assert rc == 0
assert agents_route.call_count == 0
@@ -1858,6 +2070,7 @@ class TestResolveThenRunWithPicker:
monkeypatch.setattr(RatatoskrApp, "run_async", main_sentinel)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 20
err = capsys.readouterr().err
@@ -1873,9 +2086,7 @@ class TestResolveThenRunWithPicker:
capsys: pytest.CaptureFixture[str],
) -> None:
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=[])
)
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
from ratatoskr.tui import AgentPickerApp
@@ -1888,6 +2099,7 @@ class TestResolveThenRunWithPicker:
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 13
err = capsys.readouterr().err
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.3.0"
version = "0.4.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },