Compare commits

...

3 Commits

Author SHA1 Message Date
vh 24e4371ec7 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.
2026-05-23 21:41:16 -07:00
vh d30be12deb feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:

- ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen
  dataclass with omit-when-null/empty defaults mirroring SessionInfo's
  INV-001/INV-002 origin-conditional pattern. Non-200 responses raise
  the existing SessionApiFailed (no new exception).
- ratatoskr.cli: `_parse_args` softens `--agent` from absolute to
  mode-conditional — required for `--send --new`, optional for bare
  `--new`, forbidden with `--session` (unchanged INV-004).
- ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate
  Textual App (not Screen-within-RatatoskrApp) so list_agents errors
  land on real stderr before any alt-screen opens (preserves issue
  #6's INV-001). `_resolve_then_run` gains a pre-create branch:
  fetch agents → empty list → exit 13; non-200 → exit 20; network
  error → exit 21; picker dismissed → exit 0; otherwise thread chosen
  agent_id into create_session.

Contract: docs/contracts/issues/8.contract.md (drift-check clean).

Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
2026-05-23 17:58:22 -07:00
vh a77a872810 snapshot: persistent-memory — v0.2.1 layout fix + §5 sequencing decision + Worldtree-stall diagnostic
Captures three things accumulated since the v0.2.0 snapshot in 3b9c610:

1. v0.2.1 layout fix (c85f6bd) — Recent decision documenting the
   dock-anchored TUI chrome that fixed Input bouncing. Operator-verified
   "a lot better" interactively. Going-forward principle: TUI-layout
   patches ship + operator verifies (TTY is the load-bearing test
   surface; respx/Pilot can't catch screen-relative positioning bugs).

2. §5 sequencing decision — collapsible Thinking pane + Debug pane
   proposals fold into design-brief §5's TabbedContent column rather
   than ship as inline-Collapsibles first. Do issue #8 (startup agent
   picker) before §5. Avoids the build-inline-then-rebuild waste.

3. Worldtree-stall diagnostic shorthand — "2-events-then-silence"
   = upstream LLM-provider connection wedged, not ratatoskr.
   worldtree-dev confirmed via code-level walk-through (althing
   01KSBKTG096Q…). Future-self defense against bisecting ratatoskr
   code when this shape appears.

Also refreshes the in-flight section: v0.2.0 + v0.2.1 shipped + tagged;
lofn smoke now unblocked on auth (still pending operator); next planned
feature is issue #8 (startup agent picker), then §5 side-panes.

No version bump per CLAUDE.md SemVer etiquette (memory-snapshot
commits skip).
2026-05-23 17:45:21 -07:00
11 changed files with 1683 additions and 234 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.)
+323
View File
@@ -0,0 +1,323 @@
---
contract_version: "2.1"
target_module: "ratatoskr.sessions"
scope: "Startup agent picker for TUI mode when `--new` is passed without `--agent`. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions` gains `list_agents()` (GET /agents) returning `list[AgentInfo]` (new frozen dataclass with omit-when-null defaults mirroring SessionInfo's INV-001/INV-002 pattern); `ratatoskr.cli` softens `--agent` requirement from absolute to mode-conditional (`--send --new` still requires it; bare `--new` accepts None; `--session` still forbids it); `ratatoskr.tui._resolve_then_run` gains a pre-create branch that, when `args.new and args.agent_id is None`, calls `list_agents(client)` then runs a dedicated tiny `AgentPickerApp` (separate Textual `App` instance, opens before the main `RatatoskrApp`) whose `run_async()` returns the chosen `agent_id` (or `None` on Esc/Ctrl-D for clean exit). No new files; no wire-level surface change beyond the new endpoint hit. Composes naturally with #5 (`--end-user-id`): both thread through `ParsedArgs` before any App opens."
depends_on:
- "httpx"
- "textual"
used_by:
- "ratatoskr.cli"
- "ratatoskr.tui"
language: "python"
complexity: "low"
estimated_loc: 200
confidence: 0.85
assumptions:
- "Worldtree spec pin (`docs/conversation-api-spec.md` v0.19.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) declares `GET /agents` at §832: returns 200 with a JSON array of agent objects. Three fields always present (`agent_id`, `name`, `description`); five optional with omit-when-null/omit-when-empty semantics (`version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). No pagination, no filters, no auth-scope requirement beyond bearer-authenticated (worldtree-dev confirmed 2026-05-23: Tier 1 `agent.list` baseline scope covers it; same auth posture as POST /sessions)."
- "`AgentInfo` is a frozen dataclass on `ratatoskr.sessions` (alongside `SessionInfo` / `SessionPage`) carrying all 8 fields. Origin-conditional defaults mirror INV-001/INV-002 from issue #2: required fields (`agent_id`, `name`, `description`) take the response value verbatim; optional fields default to `None` (scalar `version`) or empty container (`capabilities=[]`, `supported_models=[]`, `persona_traits={}`, `ui_hints={}`) when omitted from the response. Empty containers (NOT `None`) for collection-shaped optionals so caller code can branch on truthiness without `is None` ceremony."
- "`list_agents` uses the same caller-owned-client posture as `create_session` / `list_sessions`: takes `httpx.AsyncClient`, returns `list[AgentInfo]`, raises `SessionApiFailed(status, body)` on any non-200 response. No new exception type — list_agents' failure modes (auth, transport, server-side 5xx) all map cleanly to the existing `SessionApiFailed` shape. The module's posture against shared types with sse_client (`ratatoskr.sessions` issue #1 convention dependency) is preserved."
- "**CLI surface change is mode-conditional, not flag-removal**: `--agent` becomes optional ONLY when `--new` is passed AND `--send` is NOT passed (i.e., bare TUI-mode create). `--send --new` still raises `UsageError('--agent is required when --new is passed in --send mode')` because non-interactive --send mode has no way to prompt. `--session <id>` still forbids `--agent` (preserves issue #3 INV-004 mutual-exclusion). The single existing line `if ns.new and not ns.agent: raise UsageError(...)` in `_parse_args` STEP 3 splits into two conditionals that distinguish on `ns.send`."
- "**Picker is its own App, not a Screen within RatatoskrApp** (deliberate divergence from the issue body's 'pushed onto the App's screen stack' phrasing, which predated issue #6's refactor). Reason: issue #6's load-bearing invariant is that session-resolution + startup errors land on the operator's REAL stderr before any alt-screen opens. `list_agents` failures (network, auth, server error) need that same property. Doing it via a Screen inside RatatoskrApp re-introduces the alt-screen-eats-stderr problem #6 fixed. Doing it via a separate `AgentPickerApp` opened in `_resolve_then_run` (before `RatatoskrApp`) preserves #6's invariant: `list_agents` errors print to stderr and short-circuit BEFORE the picker's alt-screen opens; picker errors (which don't really exist — it's pure UI navigation) are bounded; chosen `agent_id` flows back through `app.run_async()`'s return value."
- "**Two alt-screen cycles is acceptable** (picker opens + closes; main RatatoskrApp opens). Textual's `App.run_async()` handles alt-screen entry + restoration cleanly per-instance. The visible-flicker cost is one quick alt-screen flash between picker dismissal and main App mount; the architectural cost of avoiding this (Screen-within-App, breaking #6) is higher than the cosmetic cost. If empirical operator feedback indicates the flicker is jarring, follow-up issue collapses to one App with two Screens AFTER re-engineering the stderr-error path."
- "**Picker Esc/Ctrl-D returns exit 0, NOT a `UsageError`**: when the operator dismisses the picker without choosing, the intent is 'never mind, exit cleanly' — same as Ctrl-D from the main chat pane in INV-002 of issue #4. `_resolve_then_run` returns 0 without calling `create_session` or `App.run_async()` on RatatoskrApp. No session is created server-side; no `agent_id` is required to satisfy this exit path."
- "**Picker layout uses ListView, not DataTable**: design-brief §5 mentions `DataTable` for the session picker but ListView is the right primitive for agent picking — single-column, keyboard-navigable, one row per agent rendered as `agent_id · name — description`. v1 picker is a flat list per the issue's out-of-scope clause (search/filter/sort/ui_hints rendering all deferred). DataTable's column-header + sortable-column ergonomics are wasted on this surface."
- "**Empty agent list is a clean exit, not an error**: if `GET /agents` returns `[]`, the picker writes `[no_agents] server returned empty agent list\\n` to stderr and `_resolve_then_run` returns exit code 13 (new — see ERROR_ROUTING below). The picker UI never opens in this case; no point showing an empty list with no actionable rows."
- "**Single agent does NOT auto-select**: if `GET /agents` returns one agent, the picker still opens with one row. Auto-select would hide the choice (and the agent's description) from the operator. The cost is one keystroke; the benefit is transparency about what's about to happen."
- "**`AgentInfo` field order in the dataclass matches the spec's column order** (`agent_id`, `name`, `description`, `version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). Mirrors how readers scanning the dataclass map mental model from spec → code."
- "**`AgentInfo.persona_traits` / `ui_hints` are typed as `dict[str, Any]` not nested dataclasses**: v1 picker just displays `agent_id · name — description`; the inner shape (ocean object, icon, color_hint, vibe) is opaque to ratatoskr. Future polish that renders icon/color_hint would either parse on-demand or introduce nested dataclasses then. Keeping them as dict[str, Any] avoids paying a typing tax now for a display surface that's deferred."
- "**`list_agents` does NOT pass query params**: spec §832 declares no pagination, no filters. The request is a bare `GET /agents` with the bearer header from the caller-owned client. If Worldtree later adds filters (e.g., `?capability=foo`), `list_agents` gains them via amendment then."
open_questions:
- "Should the picker display `version` when available (e.g., `mimir v0.2.0 — Keeper of the Well of Knowledge`)? Draft: no for v1 — the issue body specifies `agent_id · name — description` exactly. Add in a follow-up if operators report ambiguity (two `mimir` rows from different deployments). Drift-check evidence first."
- "Should `AgentPickerApp.run_async()` return the chosen `AgentInfo` or just the `agent_id` string? Draft: just the `agent_id` string for v1 — that's all `create_session` needs. Returning the full `AgentInfo` would let `RatatoskrApp` show name/description in the identity widget without re-fetching, but the existing identity widget format is `<agent_id> · …<session_id>` so the extra metadata has no consumer yet. Defer until §5 side-panes work needs it."
- "Should the picker show a loading spinner while `list_agents` is in flight? Draft: no for v1 — list_agents runs BEFORE the picker App opens (per the architectural decision above), so there's no in-app loading state to show. Operator sees stderr label on failure; on success the picker opens with the list already populated. If the request latency turns out to be noticeable (e.g., >300ms), reconsider."
prd:
issue: 8
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/8"
body_sha256_16: "c34f4878936a4edc"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-24T00:49:03+00:00"
dependencies:
- issue: 2
path: "src/ratatoskr/sessions.py"
reason: "In-place contract amendment: new `list_agents()` function + new `AgentInfo` frozen dataclass added to the module's public surface. POST /sessions paths unchanged; no shared types with the new code beyond the module's existing `SessionApiFailed` exception (reused for non-200 responses)."
- issue: 3
path: "src/ratatoskr/cli.py"
reason: "In-place contract amendment: `_parse_args` STEP 3 splits the single `if ns.new and not ns.agent` check into two conditionals — `--send --new` keeps the strict requirement; bare `--new` accepts `agent_id=None` for the TUI-picker case."
- issue: 4
path: "src/ratatoskr/tui.py"
reason: "In-place contract amendment: new `AgentPickerApp(App[str | None])` class with ListView + Enter/Esc bindings; `_resolve_then_run` gains a pre-create branch that runs the picker when `args.new and args.agent_id is None`; on chosen `agent_id`, threads it into `create_session(client, chosen, end_user_id=args.end_user_id)`. RatatoskrApp itself is unchanged."
---
# Startup agent picker — GET /agents when --new without --agent (TUI)
## Context
Today, `ratatoskr --new` requires `--agent <id>`. If omitted, `_parse_args`
raises `UsageError("--agent is required when --new is passed")`. That's
correct for `--send --new` (non-interactive — can't prompt) but wrong
for the TUI (operator may not know which agents are available, would
prefer to pick from a list at startup).
Worldtree's spec §832 exposes `GET /agents`. The endpoint returns a flat
JSON array; required fields are `agent_id`, `name`, `description`;
optional fields (`version`, `capabilities`, `supported_models`,
`persona_traits`, `ui_hints`) follow omit-when-null/empty rules. No
pagination, no filters, no special scope. Worldtree-dev confirmed
2026-05-23: Tier 1 baseline auth covers it.
This issue threads a small surface change through three existing modules
in-place — no new files apart from this contract.
## Data flow
**Input:**
- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client).
- No request body, no query params.
**Output (`list_agents`):**
- `list[AgentInfo]` — one entry per available agent, in server-declared order.
**Output (`AgentPickerApp.run_async()`):**
- `str | None` — chosen `agent_id`, or `None` on Esc/Ctrl-D dismissal.
## Public surface (ratatoskr.sessions amendment)
```python
@dataclass(frozen=True)
class AgentInfo:
"""One agent's metadata from GET /agents.
INV-005: Required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None (`version`)
or an empty container (`capabilities`, `supported_models`,
`persona_traits`, `ui_hints`) when omitted from the server response,
mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern.
"""
agent_id: str
name: str
description: str
version: str | None
capabilities: list[str]
supported_models: list[str]
persona_traits: dict[str, Any]
ui_hints: dict[str, Any]
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents → list of available agents. See contract FN list_agents."""
```
## Functions
### FN list_agents
```
FN list_agents(client: httpx.AsyncClient) -> list[AgentInfo]
BRIEF: GET /agents → list of available agents. No pagination, no filters.
PRE-001: client is not None.
STEPS:
1. resp = await client.get("/agents")
2. IF resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
3. body = resp.json() # expected: list[dict]
4. items = [
AgentInfo(
agent_id = item["agent_id"],
name = item["name"],
description = item["description"],
version = item.get("version"),
capabilities = item.get("capabilities") or [],
supported_models = item.get("supported_models") or [],
persona_traits = item.get("persona_traits") or {},
ui_hints = item.get("ui_hints") or {},
)
for item in body
]
5. RETURN items
POST-001: every item in the return list has required fields populated.
POST-002: optional fields default to None / [] / {} when absent from the response.
ERROR_ROUTING:
- 200 with non-list body → KeyError / TypeError propagates (server bug; not handled here).
- non-200 → SessionApiFailed(status=N, body=resp.content)
- httpx.RequestError → propagates (network failure; caller handles).
TESTS:
- test_happy_full_shape: 200 + spec's full-shape mimir example → AgentInfo with all fields populated.
- test_happy_minimum_shape: 200 + spec's minimum-shape "minimal" example → AgentInfo with required + defaulted optional.
- test_happy_multi_agent: 200 + array of 3 agents → list of 3 AgentInfo in order.
- test_happy_empty: 200 + [] → empty list (no error).
- test_omit_capabilities: 200 + agent missing capabilities → AgentInfo.capabilities == [].
- test_omit_persona_traits: 200 + agent missing persona_traits → AgentInfo.persona_traits == {}.
- test_omit_ui_hints: 200 + agent missing ui_hints → AgentInfo.ui_hints == {}.
- test_500_raises: 500 → SessionApiFailed with status=500.
- test_401_raises: 401 → SessionApiFailed with status=401.
```
## CLI surface change (ratatoskr.cli amendment)
`_parse_args` STEP 3, currently:
```python
if ns.new and not ns.agent:
raise UsageError("--agent is required when --new is passed")
```
becomes:
```python
if ns.new and not ns.agent:
if ns.send is not None:
# --send --new mode: non-interactive, cannot prompt for choice.
raise UsageError("--agent is required with --new in --send mode")
# else: bare --new (TUI mode) — agent_id stays None, TUI runs picker.
```
INV-004 (`--session` + `--agent` mutual exclusion) is unchanged. Existing
`--send --new` smoke flows that pass `--agent` continue to work unchanged.
ERROR_ROUTING (cli):
- `--send --new` without `--agent``UsageError` → exit 10 (unchanged from today).
- bare `--new` without `--agent``ParsedArgs.agent_id=None`, TUI handles.
TESTS (additions to test_cli.py):
- test_parse_send_new_without_agent_raises: `--send "hi" --new` (no --agent) → UsageError.
- test_parse_bare_new_without_agent_accepted: `--new` (no --agent, no --send) → ParsedArgs with agent_id=None.
- test_parse_bare_new_with_agent_accepted: `--new --agent mimir` → unchanged behavior, agent_id="mimir".
## TUI surface change (ratatoskr.tui amendment)
### New: AgentPickerApp
```python
class AgentPickerApp(App[str | None]):
"""Single-purpose picker App. Opens before RatatoskrApp.
`run_async()` returns the chosen agent_id (str) or None on Esc/Ctrl-D.
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("enter", "pick", "Pick", priority=True),
Binding("escape", "dismiss", "Cancel", priority=True),
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
]
def __init__(self, agents: list[AgentInfo]) -> None:
super().__init__()
assert agents # PRE-002 — caller guarantees non-empty
self.agents = agents
def compose(self) -> ComposeResult:
yield Header()
yield Static("Pick an agent for the new session:", id="picker-prompt")
yield ListView(
*[
ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
for a in self.agents
],
id="agent-list",
)
yield Footer()
async def on_mount(self) -> None:
self.query_one("#agent-list", ListView).focus()
def action_pick(self) -> None:
lv = self.query_one("#agent-list", ListView)
idx = lv.index
if idx is None:
return # no row highlighted; ignore
self.exit(self.agents[idx].agent_id)
def action_dismiss(self) -> None:
self.exit(None)
```
### Modified: _resolve_then_run
Insert a pre-create branch between the `async with httpx.AsyncClient(...)`
and the existing `if args.new:` block:
```python
async def _resolve_then_run(args: ParsedArgs) -> int:
assert isinstance(args, ParsedArgs) and args.send_content is None
async with httpx.AsyncClient(...) as client:
# NEW (issue #8): startup agent picker when --new without --agent.
chosen_agent_id: str | None = args.agent_id
if args.new and args.agent_id is None:
try:
agents = await list_agents(client)
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
if not agents:
sys.stderr.write("[no_agents] server returned empty agent list\n")
return 13
picker = AgentPickerApp(agents)
chosen_agent_id = await picker.run_async()
if chosen_agent_id is None:
return 0 # Esc/Ctrl-D — clean exit, no session created
# EXISTING: create_session OR re-use --session id
if args.new:
assert chosen_agent_id is not None
try:
info = await create_session(
client, chosen_agent_id, end_user_id=args.end_user_id
)
...
else:
...
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
...
```
### ERROR_ROUTING (tui startup)
| Failure | Stderr label | Exit |
|---------|--------------|------|
| `list_agents``SessionApiFailed` | `[session_api_failed] status=N body=B` | 20 |
| `list_agents` → network error | `[network_error] T: M` | 21 |
| empty agent list (`GET /agents` returns `[]`) | `[no_agents] server returned empty agent list` | 13 (new) |
| picker Esc/Ctrl-D dismissal | (none — clean exit) | 0 |
| `create_session` post-pick → `AgentNotFound` | `[agent_not_found] agent_id=X` | 12 (unchanged from #4) |
INV: exit code 13 is new (no-agents). Previously unused — does not collide
with any existing exit code in `_resolve_then_run` or `_amain`.
### TESTS (additions to test_tui.py)
```
- test_picker_happy_path: list_agents returns 2 agents; AgentPickerApp opens; user picks index 0; chosen agent_id flows into create_session; main App opens.
- test_picker_esc_clean_exit: list_agents returns 2 agents; picker opens; user presses Esc; _resolve_then_run returns 0; create_session NOT called; RatatoskrApp NOT opened.
- test_picker_skipped_when_agent_id_provided: bare --new --agent mimir → list_agents NOT called; picker NOT opened; create_session called with "mimir".
- test_picker_skipped_when_session_mode: --session s-1 → list_agents NOT called; picker NOT opened; no create_session.
- test_picker_list_agents_session_api_failed: list_agents raises SessionApiFailed → stderr [session_api_failed]; exit 20; picker NOT opened; create_session NOT called.
- test_picker_list_agents_network_error: list_agents raises ConnectError → stderr [network_error]; exit 21.
- test_picker_empty_list: list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened; create_session NOT called.
- test_agent_picker_app_renders_rows: AgentPickerApp with 3 agents → ListView has 3 ListItem children with expected text.
- test_agent_picker_app_pick_returns_agent_id: simulate Enter on highlighted row → exit value == agents[idx].agent_id.
- test_agent_picker_app_dismiss_returns_none: simulate Esc → exit value is None.
```
## Invariants
- **INV-005**: `AgentInfo` field defaults are origin-conditional (mirrors INV-001/002 from #2). Required → verbatim; optional → None / [] / {}.
- **INV-006**: `list_agents` failure modes route through `SessionApiFailed` only — no new exception type introduced.
- **INV-007**: Picker is a separate App, opened by `_resolve_then_run` BEFORE `RatatoskrApp`. Preserves issue #6's stderr-error invariant for `list_agents` failures.
- **INV-008**: `--agent` CLI requirement is mode-conditional: required only when `--send --new`; bare `--new` accepts None; `--session` always forbids it.
- **INV-009**: Picker dismissal (Esc/Ctrl-D) returns clean exit 0; no session created server-side.
- **INV-010**: Empty agent list is a clean stderr exit (code 13), not an open picker.
- **INV-011**: Single-agent response still opens the picker — no auto-select.
- **INV-012**: Two alt-screen cycles (picker + main App) is the explicit architectural tradeoff for preserving INV-007.
+77 -57
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-05-23_
_Last updated: 2026-05-24_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -32,77 +32,94 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-23 (end of day, post-#12 implementation, pre-commit):_
_As of 2026-05-24 (post-v0.4.0 §5 entry point: layout reshape +
Tools pane):_
**Status: issue #12 (presenter contract semantics amendment)
TDD-complete, in working tree, awaiting commit.** Seven 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) + robustness fix #7 (MalformedSseData
+ empty-skip). 208/208 tests GREEN; ruff clean. pyproject.toml bumped
to v0.2.0; `uv.lock` refreshed. Working tree has 9 modified files +
the new `docs/contracts/issues/12.contract.md` (untracked); commit not
yet authored.
**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, §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`:
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun (post-v0.1.0)
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up (tagged v0.1.0)
- 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
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev follow-up (v0.1.0)
`--send` validated end-to-end against personal Worldtree at v0.1.0
(`http://10.250.50.152:8081`, mimir on qwen3.6-35-a3b, 2026-05-23 smoke
returned `[done] turn_id=116 duration_ms=5467`). Lofn smoke is
**auth-unblocked** as of 2026-05-23 — worldtree-dev confirmed our key
(`c990f0be`) already covers Tier 1 agents via the `agent.call:*`
baseline policy; the initial "scope-add needed" diagnosis was a phantom
ask (see Tried-and-abandoned). The actual lofn fix shipped as issue #5
(`--end-user-id` flag).
**Async cross-frontier activity in flight:**
- Issue #12 code-review consult posted to volva 2026-05-23 (althing
thread `01KSBH8GYH4G3H03T767X613W7`). Reply pending in inbox.
**Smoke status:**
- `--send --new --agent mimir` v0.3.0 smoke clean
(`[done] turn_id=141 model=qwen3.6-35-a3b duration=2.2s`).
- Live `list_agents` smoke against personal Worldtree returned 12
agents (actor, bragi, cara, domari, forseti, glados, leif, lofn,
mimir, soong, troi, saga).
- Picker end-to-end smoke against live Worldtree: bare `--new`
list_agents → picker (auto-picked lofn programmatically since
driving alt-screen interactively from CLI smoke isn't possible)
→ POST /sessions with end_user_id="ratatoskr-tui" succeeded;
RatatoskrApp constructed with agent_id="lofn".
- TUI v0.2.0 was visually broken (Input pane bouncing with thinking
runs); v0.2.1 fixed via dock-based layout. Operator confirmed
"a lot better" interactively.
**Outstanding operator-side todos:**
- **Commit issue #12 work** + tag v0.2.0 + push. 9 modified files +
new `12.contract.md` ready.
- **Post-v0.2.0 mimir smoke (the visual one)** — `source env.sh && uv
run ratatoskr --new --agent mimir --send "test"` to eyeball the new
rendering (`. thinking: ...` coalesce, `. worker_phase: ...` demotion,
`duration=5.5s` formatting, `usage 6756 in -> 126 out (...)` shape).
The v0.1.0 mimir smoke confirmed wire-level backwards compat but
did NOT exercise the v0.2.0 rendering.
- **Post-v0.2.0 lofn smoke** — `source env.sh && uv run ratatoskr
--new --agent lofn --send "hello"` (env.sh ships
`RATATOSKR_END_USER_ID="ratatoskr-tui"`). Now unblocked on auth.
- **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.
**Pending issues filed but not started:**
- **Issue #8 (startup agent picker)** — filed but unscaffolded.
`GET /agents` is free to call (worldtree-dev confirmed); auth side
is unblocked. Depends on #5 composably (both thread through
`ParsedArgs` → `_resolve_then_run`).
- **Issue #9 (spec-pin refresh v0.19.0 → v0.22.1)** — filed
2026-05-23. Documentation debt. None of the v0.20.0/v0.21.0/v0.22.0
changes break ratatoskr's existing surface; the pin lies about
what we've committed to.
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23 to
track Worldtree #196's LOCKED-but-not-shipped breaking change.
Don't pre-implement per worldtree-dev's explicit guidance.
2026-05-23. Documentation debt; defer unless we need a v0.20.0+
capability.
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23
to track Worldtree #196. Don't pre-implement per worldtree-dev.
- **Issue #11 (AdminEvents pane auth prerequisite)** — filed
2026-05-23. Future side-pane requires `admin.events.read` scope.
2026-05-23. Future side-pane needs `admin.events.read` scope.
Branch: `main` (dirty with #12 work pending commit). Remote:
**Pending Worldtree-dev follow-up:**
- worldtree-dev committed (althing `01KSBKTG096Q…`) to file a
Worldtree-side issue for the stall-watchdog gap (cancel-check is
inside the engine-event loop, so a never-yielding first-LLM-call
bypasses the 300s watchdog). Will file after the immediate stall
is cleared.
- Ratatoskr-side companion (potential): a client-side stall watchdog
(e.g., 90s-no-events → `[server_stalled]` stderr label, keep
connection). Defer until recurrence; defense-in-depth regardless of
whether Worldtree fixes its own.
Branch: `main` (clean). Remote:
`origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
**Next natural moves:**
1. **Triage volva's #12 code-review** when the reply lands in the
inbox; apply tactical fixes inline, surface architectural calls.
2. **Commit + tag v0.2.0 + push.**
3. **Post-v0.2.0 smokes** — mimir (visual), lofn (newly unblocked).
4. **Issue #8 (startup agent picker)** — scaffold + contract, then
TDD. Composes with the forward end_user_id direction (see Recent
decisions).
5. **Side-pane issues** — Persona pane first (file-tail, cheap).
6. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
1. **Interactive picker eyeball** — operator confirms the TUI
picker UX (rendering, Enter pick, Esc dismiss) against personal
Worldtree.
2. **§5 side-panes work** — Persona pane first per design-brief; the
collapsible Thinking pane + Debug pane proposals fold IN as
additional `TabbedContent` tabs alongside Persona/Tools/AdminEvents.
Reshapes layout from vertical-stack to Horizontal two-column.
3. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
capability (e.g., `memory_context` for Phase 2.1).
## Recent decisions
@@ -134,6 +151,8 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-05-23]` **Issue #5 (`--end-user-id`) implemented via TDD.** Small surface change across three modules (sessions, cli, tui): `create_session(client, agent_id, *, end_user_id=None)` widens with optional kwarg; body conditionally adds the field when non-None (INV-002: omitting != sending empty); PRE-003 asserts non-empty. `ParsedArgs.end_user_id: str | None = None` field; `--end-user-id` CLI flag with non-empty validation (mirrors `--send` check). `_amain` and `_resolve_then_run` thread `end_user_id=args.end_user_id` to their `create_session` calls. Post-#6 adjustment: the contract originally named `on_mount` as the TUI threading site, but #6 had moved session resolution to `_resolve_then_run` — same shape, different function. 7 new tests across the 3 modules.
- `[2026-05-23]` **Worldtree-dev consult landed authoritative consumer-API guidance** (althing thread `01KSBARG2B8M8C82H6AJGJWX1B`). Key takeaways shaped follow-on work: (1) `end_user_id` is a free-form partition key for long-term memory + persona/valence state; same value → same partition, different values → fully isolated. For Vuong-debugging-Worldtree the recommended posture is a project-stable default with `--end-user-id` override. (2) No programmatic `requires_end_user_id` discovery on `GET /agents` — "try and react to 422" remains the pattern. (3) Breaking-change #196 LOCKED but not shipped: `subject:{type,id}` replaces `end_user_id` at future v0.22.x or v0.23.0; don't pre-implement. (4) Spec pin (v0.19.0) is 3 minor versions stale (current v0.22.1); none of v0.20.0/v0.21.0/v0.22.0 break ratatoskr's surface but the pin lies about what we're committed to. (5) User-Agent header: send one (`ratatoskr/<version> (vh@phasefinal.com)`). (6) `agents.call:lofn` scope needed for lofn smoke. (7) `GET /agents` requires no special scope; issue #8 unblocked on auth.
- `[2026-05-23]` **Follow-up acted on:** User-Agent header added to both `_amain` and `_resolve_then_run` httpx.AsyncClient constructions (with `importlib.metadata` version lookup + fallback to `0.0.0`); `RATATOSKR_END_USER_ID` env-var fallback added to `_parse_args` (resolution: flag > env > None); env.sh ships `RATATOSKR_END_USER_ID="ratatoskr-tui"` as project-stable default. Original issue #5 posture rejected env-var fallback as "papering over isolation"; revised after worldtree-dev's guidance that the realistic single-operator use case wants partition continuity. Issue #5 + #3 contracts amended in-place to document the env-var fallback. Infra-ops pinged via althing for `agents.call:lofn` scope (broker pattern; they forwarded to worldtree-dev). Three Gitea issues filed: #9 (spec-pin refresh), #10 (subject:{type,id} migration tracking), #11 (AdminEvents pane auth prereq).
- `[2026-05-23]` **v0.2.1 layout fix: dock-anchored TUI chrome so Input never moves** (commit `c85f6bd`, tag `v0.2.1`). Reported during the v0.2.0 mimir TUI smoke: Input bouncing up/down throughout a turn, tokens landing at shifting screen positions. Cause: v0.2.0's `Static(id="thinking-current")` was yielded between `hint` and `Footer` in the auto-stacked vertical flow, so each `display=True/False` toggle per thinking-run shifted Input + identity + hint vertically; RichLog growth from streaming text also drifted Input downward. Fix: `RatatoskrApp.DEFAULT_CSS` docks the chrome to screen edges — `thinking-current` docks top under Header; `transcript` (RichLog) gets `height: 1fr` and absorbs all reflows internally via its scroll viewport; `prompt`, `identity`, `hint` all dock bottom (locked above Footer). Compose order moved `thinking-current` to position 2 (right after Header) so source-order matches the dock layout. **Operator-confirmed "a lot better"** interactively. Pure UI fix; no public API change; tests pass without modification. v0.2.0 → v0.2.1 (patch). I couldn't verify in a TTY from this non-interactive session — the design was sound enough to ship blind, with operator verification post-commit. Going forward: TUI-layout patches like this are "ship + operator verifies" since the TTY is the load-bearing test surface and respx + Pilot mocks can't catch screen-relative positioning bugs.
- `[2026-05-23]` **Sequencing decision: design-brief §5 side-panes work absorbs the inline collapsible-Thinking-pane + Debug-pane proposals; do issue #8 (startup agent picker) BEFORE §5.** Surfaced during the v0.2.1 follow-up discussion. The operator's proposal — "create a collapsible pane for all thinking tokens; text_boundary goes to a debug pane" — is exactly §5-shaped work (the design-brief proposes a `Horizontal` two-column layout with `TabbedContent` for Persona/Tools/AdminEvents/BifrostState/ServerLog). Building inline-Collapsibles now and then rebuilding as `TabbedContent` panes at §5 would be wasted work. So: do #8 first (independent surface, no layout overlap), then §5 (which folds in Thinking + Debug panes alongside the design-brief's named §5 panes). Interim acceptance: v0.2.1 fixes the structural layout-bouncing pain; transcript-dominated-by-thinking is still real but doesn't degrade further — operator can scroll back, Input doesn't move, tokens land predictably. The interim "noisy transcript" pain is real but bounded; §5 work resolves it cleanly.
- `[2026-05-23]` **Issue #12 (presenter contract semantics amendment) implemented via TDD.** Headline: thinking deltas render as ONE coalesced growing line (CLI) / one closed RichLog entry per run + live Static(id="thinking-current") widget per-delta (TUI), not 50 lines per turn. Introduced stateful per-turn presenters: `CliPresenterState` (cli.py) and `TuiPresenterState` (tui.py), both `@dataclass(slots=True)` with thinking_buffer + thinking_open (+ text_written_since_newline for CLI). Editorial promotion line settled: load-bearing = Text/Done/Error/Cancelled (no prefix); demoted telemetry = WorkerPhase/Thinking/TextBoundary/ToolStart/ToolResult (CLI `. ` ASCII prefix; TUI `· ` Unicode dim prefix). CLI stdout/stderr newline-boundary INV-005: when text was streamed mid-line, flush a `\n` to stdout before writing terminal labels to stderr; `text_written_since_newline = not event.content.endswith("\n")` per Volva F4 fix. Helpers `_format_duration_ms` (`347ms` / `5.5s` / `1.2m` autoscale) and `_format_usage` (`6756 in -> 126 out (6882 total, 0 cached)` with arrow="->" CLI or "→" TUI). Per Vor (eitri-smithy-dev cross-frontier consult, althing 01KSBE52YZR5) + Volva paraphrase (5 contract-text ambiguities all fixed in #12.contract.md). `[create_session]` lifecycle line demoted to `. create_session:` (written directly by `_amain`, bypasses state.render). Old `_render_event` / `_render_event_to_log` functions and their TestRenderEvent/TestRenderEventToLog classes removed (no-backwards-compat rule). Contracts amended: #3 (CliPresenterState block + `_run_turn` thread state + `_amain` create_session demotion + `_format_*` helper blocks), #4 (TuiPresenterState block + `_stream_turn_worker` state construction + `compose` Static widget addition). 39 new tests; 19 obsolete tests removed; net 208 GREEN. v0.1.0 → v0.2.0 (minor; pre-amendment output shape broken intentionally — scripts grepping `[thinking] '` no longer work; that's the intended cleanup). Cross-frontier design pass with eitri-smithy-dev returned 16-of-16 confirmed decisions + 4 material divergences applied (ASCII `· ` factual fix, RichLog-one-entry-per-run vs inline-mirror, presenter-state object vs stateless, "contract semantics amendment" framing not "polish"). Calibration note: eitri-smithy-dev's value here was *architectural* (state-object pattern + chronological-vs-live decoupling) not just *tactical*; the framing rename alone justified the consult. Volva paraphrase round added 5 prose-precision fixes (INV-001 "growing display" semantics, TUI hide mechanism unification, render_error security/readability tension, newline-tracking corner case, [create_session] integration path).
- `[2026-05-23]` **Forward direction: Ratatoskr will require `end_user_id` for EVERY access before too long.** Operator's call. Reasoning: even Tier 1 foundational agents (mimir, all Asgardians) that don't *require* `end_user_id` server-side currently fall back to a `_no_end_user` sentinel substrate partition — effectively pollution from a single-operator-debug-tool's perspective. The right shape is "every conversation has an explicit partition key." `RATATOSKR_END_USER_ID="ratatoskr-tui"` env-default in env.sh is the first step toward that posture; once we've validated the partition-isolation experience, the next move is making `end_user_id` mandatory (probably remove the `None`-default in `_parse_args`, fail-closed with a UsageError if neither flag nor env provides it). Consequence for cross-project asks: declined worldtree-dev's offer to ship `requires_end_user_id: bool` on `AgentInfoResponse` because we'd treat every value as true regardless; the try-and-react-to-422 pattern goes away from our side because we never send a request without the field. File a ratatoskr issue when scheduling the change — touches `_parse_args` validation + `_resolve_then_run` + `_amain` + tests + contract amendments to #3 / #5. Treat as a v0.2.0 minor (breaking: existing `--new --agent mimir` without env or flag would start failing). **Cross-frontier alignment (worldtree-dev ack 2026-05-23, althing 01KSBD9FPMCWJMBXNNS4B3MYBS):** the platform side agrees with this framing — `_no_end_user` is a substrate accommodation for identity-less transports, NOT a consumer model. The fallback's `_is_fallback=True` trap door (#185 INV-185-5/8) "could become operator-controlled later" per worldtree-dev, meaning Worldtree itself may tighten the substrate-fallback path. Ratatoskr's forward posture pre-empts that tightening — moving from "we send end_user_id when set" to "we never send a request without end_user_id" stays consumer-correct regardless of what Worldtree does with the fallback knob.
@@ -153,4 +172,5 @@ defense against re-attempting the same cul-de-sac.
- `[2026-05-21]` **TUI session-identity rendering via `self.sub_title` + `self.hint` plain attributes.** Stored state but never rendered to a visible widget. The contract's "session-identity-always-visible" invariant was satisfied at the state-attribute level but not the user-visible-widget level. Tests asserted the attributes (which passed); Volva code-review flagged the gap. Fix: dedicated `Static(id="identity")` + `Static(id="hint")` widgets in compose; `_set_hint()` helper mirrors state → widget. Calibration evidence for the "TDD catches state, code-review catches whether the user can see it" pattern.
- `[2026-05-23]` **Using the cross-model review agent's name directly in composed prose.** The peer review agent's name (the `althing` handle starting with "V-o-l-v-a") is one letter from a body-part term. Anthropic's content classifier does fuzzy matching and intermittently blocks responses mid-stream when the name appears in composed prose sentences (especially in meta-commentary about the agent's work). Direct-quoted tool output (e.g., the `althing-cli thread` body) passes through fine. Mitigation: use role descriptions ("the cross-model reviewer," "the paraphrase peer") in prose rather than the name; quote content via tool output. Confirmed by switching to Sonnet 4.6 for a test read — same raw content read cleanly when fetched via Bash rather than composed into an LLM response. This is a persistent environmental constraint, not a one-off.
- `[2026-05-22]` **`json.loads(sse.data)` unguarded against empty data.** `_iter_events` unconditionally called `json.loads` on every dispatched `ServerSentEvent`. When `httpx_sse` surfaced a frame with `id:` present but `data:` empty (a known library-vs-spec divergence — RFC says don't dispatch; httpx_sse is permissive), `json.loads('')` raised `JSONDecodeError` → propagated through Textual's worker → app crash. Crashed mimir conversation at turn 93/seq 1078 after 1077 successful events. Fix: `if sse.data == '': continue` BEFORE `_parse_sse_id` (empty-data event with a malformed id is still a keepalive — don't reorder). Non-empty malformed data raises new `MalformedSseData(raw[:200])`. Don't reintroduce unconditional `json.loads(sse.data)`; always pre-check for the empty case.
- `[2026-05-23]` **Diagnostic shorthand: "2-events-then-silence" = Worldtree-side LLM-call wedge, not ratatoskr.** If a mimir `--send` smoke shows exactly two stderr events — `. create_session: ...` followed by `. worker_phase: phase=BuildingPrompt ...` — and then nothing for >60s, the root cause is upstream of ratatoskr. Worldtree's `service.py:2560` gates the `CallingLLM` event on the engine yielding its first LLM-provider chunk; if that provider connection is wedged at the TCP level, the `async for` never iterates and the SSE stream stays silent forever. ratatoskr's `read=None` httpx timeout (the issue #1 + #4 INV-007 fix for "5s default killed mid-stream during mimir's thinking") waits patiently as designed; there's no client-side stall watchdog above the read-timeout layer. Worldtree's OWN stall watchdog (300s `_start_stall_timer`) exists but its cancel-check is INSIDE the engine-event loop, so a never-yielding first-LLM-call bypasses it. Confirmed by worldtree-dev (althing thread `01KSBKTG096Q07JVRG41JXA1DD`). **Don't waste time bisecting ratatoskr code when this shape appears** — diagnose the LLM-provider connection state at Worldtree's host. Restarting the Worldtree service (`:8081` in our case) cleared a wedged llama-swap connection. Future ratatoskr issue worth filing if recurrence: client-side stall watchdog (e.g., 90s-no-events → `[server_stalled]` stderr label, keep connection open). Also worth knowing: 10.250.50.152 hosts 3 Worldtree instances (`:8080`, `:8081`, `:8082`) — each with its own DB and key namespace. Our key is valid only on `:8081`.
- `[2026-05-23]` **Phantom "per-Tier-1-agent scope add" pattern.** Issue #5's lofn 422 was initially diagnosed (with worldtree-dev's first reply) as needing `agents.call:lofn` added to ratatoskr's existing key. Routed through infra-ops via althing per the credential-brokerage rule; infra-ops discovered no public scope-mutation endpoint on personal Worldtree, brokered to worldtree-dev for the actual mechanism. Worldtree-dev came back with a correction: their first answer conflated two distinct Heimdall scope namespaces. **Tier 1 foundational agents** (mimir, lofn, soong, all Asgardians) are covered by a blanket `agent.call:*` (singular) baseline rule in `config/policies.yaml > tiers.<tier>.scopes` for ALL authenticated tiers including `user`. There is no per-agent grant for Tier 1 — the baseline rule covers it. **Tier 3 consumer-defined agents** (IDs containing `:`, like `vh:custom-bot`) use the plural `agents.call:<owner>:<agent>` shape granted implicitly via owning a `consumer_agents` DB row, registered through `POST /agents/define`. The two notations differ by one letter and that was the source of the confusion. **The actual lofn fix was issue #5's `--end-user-id` flag — it was always a request-body validation, not an auth-scope gate.** Don't ping infra-ops for "per-Tier-1-agent scope adds" again; the pattern is a phantom ask. Real future infra-ops asks: admin-tier key for the AdminEvents pane (`admin.events.read` scope, different tier), and Tier 3 custom-agent registration (different flow entirely, requires `POST /agents/define`).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.2.1"
version = "0.4.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+5 -2
View File
@@ -124,8 +124,11 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
raise UsageError("pass exactly one of --session or --new")
if ns.session and ns.agent:
raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent:
raise UsageError("--agent is required when --new is passed")
if ns.new and not ns.agent and ns.send is not None:
# Issue #8: --agent stays required for --send --new (non-interactive,
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
# the choice via list_agents in _resolve_then_run.
raise UsageError("--agent is required when --new is passed in --send mode")
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
if not api_key:
+47
View File
@@ -39,6 +39,26 @@ class SessionPage:
next_cursor: str | None
@dataclass(frozen=True)
class AgentInfo:
"""Worldtree agent envelope from GET /agents (issue #8).
INV-005: required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None / [] / {} when
omitted by the server, mirroring SessionInfo's INV-001/INV-002
origin-conditional defaulting.
"""
agent_id: str
name: str
description: str
version: str | None
capabilities: list[str]
supported_models: list[str]
persona_traits: dict[str, Any]
ui_hints: dict[str, Any]
class AgentNotFound(Exception):
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
@@ -153,3 +173,30 @@ async def create_session(
archived=False,
tags=[],
)
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
No request params, no pagination. Returns server-ordered list. Optional
fields are defaulted to None / [] / {} per INV-005.
"""
assert client is not None
resp = await client.get("/agents")
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return [
AgentInfo(
agent_id=item["agent_id"],
name=item["name"],
description=item["description"],
version=item.get("version"),
capabilities=item.get("capabilities") or [],
supported_models=item.get("supported_models") or [],
persona_traits=item.get("persona_traits") or {},
ui_hints=item.get("ui_hints") or {},
)
for item in body
]
+186 -33
View File
@@ -16,10 +16,28 @@ 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, 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 AgentNotFound, SessionApiFailed, create_session
from ratatoskr.sessions import (
AgentInfo,
AgentNotFound,
SessionApiFailed,
create_session,
list_agents,
)
from ratatoskr.sse_client import (
CancelAlreadyCompleted,
CancelFailed,
@@ -108,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(
@@ -188,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}"
))
@@ -207,20 +233,97 @@ 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]):
"""Startup agent picker (issue #8). Opens before RatatoskrApp when --new
is passed without --agent. `run_async()` returns the chosen agent_id (str)
or None on Esc/Ctrl-D dismissal.
Architecturally separate from RatatoskrApp (deliberate per issue #8
INV-007): keeps list_agents failures landing on real stderr before any
alt-screen opens, preserving issue #6's invariant.
"""
DEFAULT_CSS = """
#picker-prompt {
dock: top;
height: 1;
padding: 0 1;
}
#agent-list {
height: 1fr;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("enter", "pick", "Pick", priority=True),
Binding("escape", "dismiss", "Cancel", priority=True),
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
]
def __init__(self, agents: list[AgentInfo]) -> None:
super().__init__()
# PRE-002: caller (_resolve_then_run) checks for empty list and emits
# [no_agents] before constructing the picker.
assert agents
self.agents = agents
def compose(self) -> ComposeResult:
yield Header()
yield Static("Pick an agent for the new session:", id="picker-prompt")
yield ListView(
*[
ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
for a in self.agents
],
id="agent-list",
)
yield Footer()
async def on_mount(self) -> None:
self.query_one("#agent-list", ListView).focus()
def action_pick(self) -> None:
lv = self.query_one("#agent-list", ListView)
idx = lv.index
if idx is None:
return # nothing highlighted; ignore
self.exit(self.agents[idx].agent_id)
def action_dismiss(self) -> None:
self.exit(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;
@@ -235,6 +338,10 @@ class RatatoskrApp(App[int]):
dock: bottom;
height: 1;
}
#pane-name {
dock: bottom;
height: 1;
}
#hint {
dock: bottom;
height: 1;
@@ -244,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"
@@ -270,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()
@@ -344,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
@@ -401,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.
@@ -436,11 +567,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# Default 5s read timeout would kill mid-stream; disable it.
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
) as client:
# Issue #8: startup agent picker — fetch GET /agents and prompt when
# --new is passed without --agent. list_agents errors land on real
# stderr before any alt-screen opens (preserves issue #6 INV-001).
chosen_agent_id: str | None = args.agent_id
if args.new and args.agent_id is None:
try:
agents = await list_agents(client)
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
if not agents:
sys.stderr.write("[no_agents] server returned empty agent list\n")
return 13
picker = AgentPickerApp(agents)
chosen_agent_id = await picker.run_async()
if chosen_agent_id is None:
return 0 # Esc / Ctrl-D — clean exit, no session created
if args.new:
assert args.agent_id is not None
assert chosen_agent_id is not None
try:
info = await create_session(
client, args.agent_id, end_user_id=args.end_user_id
client, chosen_agent_id, end_user_id=args.end_user_id
)
except AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
+28 -2
View File
@@ -176,11 +176,37 @@ class TestParseArgs:
with pytest.raises(UsageError, match="pass exactly one"):
_parse_args(["--send", "hi", "--api-key", "k"])
def test_usage_new_without_agent(self) -> None:
"""usage_new_without_agent: --new without --agent → UsageError."""
def test_usage_send_new_without_agent(self) -> None:
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError.
--send mode is non-interactive — cannot prompt; --agent stays required.
"""
with pytest.raises(UsageError, match="--agent is required when --new"):
_parse_args(["--send", "hi", "--new", "--api-key", "k"])
def test_parse_bare_new_without_agent_accepted(self) -> None:
"""bare_new_without_agent (issue #8): --new without --send or --agent → agent_id=None.
TUI mode CAN prompt; startup picker handles the choice. _parse_args
accepts None here and the TUI's _resolve_then_run drives the picker.
"""
args = _parse_args(["--new", "--api-key", "k"])
assert args.new is True
assert args.agent_id is None
assert args.send_content is None
def test_parse_bare_new_with_agent_accepted(self) -> None:
"""bare_new_with_agent (issue #8): --new --agent mimir (no --send) → picker skipped.
Existing TUI launch path with an explicit agent_id continues to work
— _resolve_then_run sees `args.agent_id is not None` and skips the
picker entirely.
"""
args = _parse_args(["--new", "--agent", "mimir", "--api-key", "k"])
assert args.new is True
assert args.agent_id == "mimir"
assert args.send_content is None
def test_usage_session_with_agent(self) -> None:
"""usage_session_with_agent: --session AND --agent → UsageError."""
with pytest.raises(UsageError, match="forbidden with --session"):
+144
View File
@@ -5,11 +5,13 @@ import pytest
import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotFound,
InvalidCursor,
SessionApiFailed,
SessionPage,
create_session,
list_agents,
list_sessions,
)
@@ -412,3 +414,145 @@ class TestListSessions:
with pytest.raises(AssertionError):
await list_sessions(client, cursor="")
assert route.call_count == 0
# ---- Issue #8: list_agents + AgentInfo --------------------------------------
class TestListAgents:
@respx.mock
async def test_happy_full_shape(self) -> None:
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "mimir",
"name": "Mimir",
"description": "Keeper of the Well of Knowledge.",
"version": "0.2.0",
"capabilities": ["knowledge_base", "semantic_search"],
"supported_models": ["default", "heavy"],
"persona_traits": {
"ocean": {
"openness": 0.7,
"conscientiousness": 0.9,
"extraversion": 0.1,
"agreeableness": 0.5,
"neuroticism": 0.3,
},
"vibe": "contemplative",
},
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert len(agents) == 1
a = agents[0]
assert isinstance(a, AgentInfo)
assert a.agent_id == "mimir"
assert a.name == "Mimir"
assert a.description == "Keeper of the Well of Knowledge."
assert a.version == "0.2.0"
assert a.capabilities == ["knowledge_base", "semantic_search"]
assert a.supported_models == ["default", "heavy"]
assert a.persona_traits["vibe"] == "contemplative"
assert a.ui_hints["icon"] == "well"
@respx.mock
async def test_happy_minimum_shape(self) -> None:
"""happy_minimum_shape: required-only agent → optional fields default."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "minimal",
"name": "Minimal Agent",
"description": "Just a sketch.",
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
a = agents[0]
assert a.agent_id == "minimal"
assert a.version is None
assert a.capabilities == []
assert a.supported_models == []
assert a.persona_traits == {}
assert a.ui_hints == {}
@respx.mock
async def test_happy_multi_agent(self) -> None:
"""happy_multi_agent: 3 agents preserve order."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "a", "name": "A", "description": "x"},
{"agent_id": "b", "name": "B", "description": "y"},
{"agent_id": "c", "name": "C", "description": "z"},
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert [a.agent_id for a in agents] == ["a", "b", "c"]
@respx.mock
async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=[])
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents == []
@respx.mock
async def test_omit_capabilities_empty_list(self) -> None:
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "a",
"name": "A",
"description": "x",
"capabilities": [],
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents[0].capabilities == []
@respx.mock
async def test_500_raises_session_api_failed(self) -> None:
"""500 → SessionApiFailed with status=500."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(500, content=b"oops")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 500
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401 → SessionApiFailed with status=401."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 401
+666 -138
View File
File diff suppressed because it is too large Load Diff
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.2.1"
version = "0.4.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },