diff --git a/docs/contracts/issues/13.contract.md b/docs/contracts/issues/13.contract.md new file mode 100644 index 0000000..b2ae491 --- /dev/null +++ b/docs/contracts/issues/13.contract.md @@ -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= args=` to `tools_log` (not `log`). +- `ToolResult` → write `· tool_result: name= duration_ms= result=` to `tools_log` (not `log`). +- All other events → unchanged routing per issue #12 INV-005. +- Render-exception fallback (`_plain_label(event)`): write to `tools_log` if the event is `ToolStart`/`ToolResult`; write to `log` otherwise. Routing preservation under failure. + +## Keybindings (amendment) + +``` +BINDINGS: ClassVar[list[Binding]] = [ + Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True), + Binding("ctrl+d", "quit", "Exit immediately", priority=True), + Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), # NEW +] + +def action_focus_tools(self) -> None: + self.query_one(TabbedContent).active = "tools-tab" + # Input focus is preserved by Textual's default behavior — TabbedContent + # doesn't steal focus when `.active` is set programmatically. +``` + +`Ctrl+1` is the v1 entry of the design-brief `Ctrl+1..5` family. `Ctrl+2..5` get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts `Input` keeps focus across the tab switch. + +## Status footer (amendment) + +New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` widgets. v1 renders the literal string `"Tools"` set once at `on_mount`; the widget never updates after that. Dynamic updating + tab-switch handler wiring is **out of scope for #13** — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a `@on(TabbedContent.TabActivated)` handler in this issue is out of spec. + +## Invariants + +- **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr. +- **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript). +- **INV-015**: 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.) diff --git a/persistent-memory.md b/persistent-memory.md index b3ea4b7..3df6d1d 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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 #8 — startup 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. diff --git a/pyproject.toml b/pyproject.toml index 4bb2422..1261361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index f7e4cef..a0f51f3 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -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. diff --git a/tests/test_tui.py b/tests/test_tui.py index 384ca72..8e27634 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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 diff --git a/uv.lock b/uv.lock index 3e1e495..c9bb07e 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" },