From 9d469d5c67cd18110466c52e23a636fad58dd643 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 21 May 2026 00:31:15 -0700 Subject: [PATCH] contract(issue#4): author ratatoskr.tui shell + amend issue #3 cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #4: Textual TUI shell — the interactive primary presenter (design-brief §1, §5). Single chat-pane App[int] subclass + sync run_tui(args) entry. Composes existing sessions + sse_client modules (no forked API-consumption code, per design-brief §8b). Six FN blocks: run_tui, RatatoskrApp class + on_mount + on_unmount, on_input_submitted, _stream_turn_worker, _render_event_to_log, action_interrupt, action_quit, _cancel_via_sse. Nine hard invariants codifying: - INV-001: lazy-import boundary so cli.py STILL doesn't import textual at module scope (issue #3's INV-001 carried forward) - INV-002: session-identity-always-visible footer (` · …`) with explicit `` carve-out for --session without --agent - INV-003: two-stage Ctrl-C state machine (idle/streaming/cancelling) per design-brief §8c - INV-005: markdown default-on with --raw opt-out; deliberately produces streaming-deltas + post-Done markdown re-render (accepted v1 trade-off, Static-then-commit refactor deferred) - INV-007: one AsyncClient per app lifetime - INV-008: mid-session errors → idle (don't exit); only initial session-create errors exit Concurrent in-place amendment of issue #3's contract: - --send becomes optional; when omitted, send_content=None is the TUI-mode marker - --raw flag added to ParsedArgs - main dispatches via lazy `from ratatoskr.tui import run_tui` when send_content is None - _parse_args + main TESTS sections updated (no_send_marks_tui_mode replaces usage_no_send; new raw_flag_default_false / raw_flag_set / no_send_dispatches_to_tui) Volva paraphrase round on issue #4: 5 findings, all amended. (1) INV-002 `` carve-out wording. (2) Idle "Ctrl-C twice to exit" hint kept per design-brief §8c's conservative-by-design rationale; INV-003 spells out the intentional one-press-from-idle discrepancy. (3) Markdown double-render trade-off made explicit in INV-005. (4) Submit-during-streaming now writes `[busy] turn in flight; input ignored` (visible notice, not silent swallow). (5) `{!r:.200}` format spec kept with explanatory inline comment. Both contracts drift-check clean. prd: pinned to issue #4 body SHA b1e73e7d2e3dd453 at 2026-05-21T06:21:37+00:00. --- docs/contracts/issues/3.contract.md | 21 +- docs/contracts/issues/4.contract.md | 462 ++++++++++++++++++++++++++++ 2 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 docs/contracts/issues/4.contract.md diff --git a/docs/contracts/issues/3.contract.md b/docs/contracts/issues/3.contract.md index 8b2b6fd..5f97695 100644 --- a/docs/contracts/issues/3.contract.md +++ b/docs/contracts/issues/3.contract.md @@ -201,14 +201,19 @@ STEPS: RETURN 11 ON SystemExit as exc: RETURN int(exc.code) if exc.code is not None else 0 - 2. [sequential, flexibility=prescriptive] RETURN asyncio.run(_amain(args)) + 2. [branch, flexibility=prescriptive] IF args.send_content is None: + # TUI mode (issue #4 amendment) — lazy import preserves INV-001 (no textual in cli at module scope) + FROM ratatoskr.tui IMPORT run_tui + RETURN run_tui(args) + ELSE: + RETURN asyncio.run(_amain(args)) TESTS: happy_returns_amain_exit_code [happy,tracer]: argv specifies a complete --send invocation; monkeypatch _amain to return 0 → main returns 0 - usage_error_no_send [error]: argv=[] → main returns 10; stderr has "[usage_error]"; _amain never called usage_error_both_session_and_new [error]: argv has both --session and --new → returns 10; stderr "[usage_error]" auth_error_missing_key [error]: argv specifies --send/--new/--agent but neither --api-key nor WORLDTREE_API_KEY is set → returns 11; stderr "[auth_error]"; _amain never called no_argv_uses_sys_argv [trace]: argv=None → _parse_args is called with sys.argv[1:] (monkeypatched argparse capture confirms) help_exits_cleanly [happy]: argv=["--help"] → main returns 0 (or whatever code argparse exits with); _amain never called; help text was printed to stdout by argparse + no_send_dispatches_to_tui [happy]: argv omits --send → main lazy-imports run_tui and calls it (NOT _amain); send_content marker is None (issue #4 amendment) ``` ```contract @@ -245,16 +250,19 @@ ERROR_ROUTING: state_recovery: none (no resources acquired before _parse_args) STEPS: 1. [setup, flexibility=prescriptive] Construct argparse.ArgumentParser: - --send (required, str, non-empty) + --send (str, OPTIONAL — issue #4 amendment: omitted → TUI mode marker; if passed, must be non-empty) --session (str, optional) --new (bool flag, default False) --agent (str, optional — required-with-validation in step 3) --api-key (str, optional — env fallback in step 4) --server (str, optional — env fallback + default in step 5) + --raw (bool flag, default False — issue #4 amendment: TUI markdown opt-out) 2. [sequential, flexibility=prescriptive] Parse argv: TRY: ns = parser.parse_args(argv if argv is not None else sys.argv[1:]) ON SystemExit: Re-raise as UsageError with argparse's captured message + 2a. [branch, flexibility=prescriptive] IF ns.send is not None AND not ns.send: + RAISE UsageError("--send content must be non-empty") # empty-string --send still invalid 3. [branch, flexibility=prescriptive] Validate session/new/agent triad per INV-004: IF ns.session and ns.new: RAISE UsageError("--session and --new are mutually exclusive") IF NOT ns.session AND NOT ns.new: RAISE UsageError("pass exactly one of --session or --new") @@ -266,12 +274,13 @@ STEPS: 5. [sequential, flexibility=prescriptive] Resolve server_url per INV-006: server_url = ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000" 6. [cleanup] RETURN ParsedArgs( - send_content=ns.send, + send_content=ns.send, # may be None (TUI marker, issue #4 amendment) session_id=ns.session, new=ns.new, agent_id=ns.agent, api_key=api_key, server_url=server_url, + raw=ns.raw, # issue #4 amendment ) TESTS: happy_new [happy,tracer]: argv=["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs(send_content="hi", session_id=None, new=True, agent_id="mimir", api_key="k", server_url="http://localhost:8000") @@ -281,7 +290,9 @@ TESTS: server_default [trace]: argv omits --server and WORLDTREE_API_URL is unset → ParsedArgs.server_url=="http://localhost:8000" server_env_fallback [trace]: monkeypatch WORLDTREE_API_URL="http://t.local:9000"; argv omits --server → ParsedArgs.server_url=="http://t.local:9000" server_flag_beats_env [trace]: monkeypatch WORLDTREE_API_URL="env"; argv has --server "flag" → ParsedArgs.server_url=="flag" - usage_no_send [error]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → UsageError (argparse required-flag) + no_send_marks_tui_mode [happy]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs.send_content=None (TUI marker; issue #4 amendment — was UsageError pre-#4) + raw_flag_default_false [trace]: --raw absent → ParsedArgs.raw==False (issue #4 amendment) + raw_flag_set [trace]: --raw → ParsedArgs.raw==True (issue #4 amendment) usage_both_session_and_new [adversarial]: argv has both --session and --new → UsageError("mutually exclusive") usage_neither_session_nor_new [adversarial]: argv has --send but neither --session nor --new → UsageError("pass exactly one") usage_new_without_agent [adversarial]: argv=["--send","hi","--new","--api-key","k"] → UsageError("--agent is required when --new") diff --git a/docs/contracts/issues/4.contract.md b/docs/contracts/issues/4.contract.md new file mode 100644 index 0000000..5429339 --- /dev/null +++ b/docs/contracts/issues/4.contract.md @@ -0,0 +1,462 @@ +--- +contract_version: "2.1" +target_module: "ratatoskr.tui" +scope: "Implement the Textual TUI shell — the interactive primary presenter (design-brief §1, §5). One `RatatoskrApp` Textual `App[int]` subclass plus a sync `run_tui(args) -> int` entry point. Layout: Header + RichLog (transcript) + Input (prompt) + Footer (session-identity-always-visible per design-brief §4). Bindings: two-stage Ctrl-C (cancel → exit per §8c); Ctrl-D immediate exit. Markdown rendering on agent output by default; `--raw` opt-out. Composes `ratatoskr.sessions.create_session` (when `--new`) with `ratatoskr.sse_client.stream_turn` + `cancel_turn`. Also amends `ratatoskr.cli`: `--send` becomes optional, and when omitted `main` dispatches to `ratatoskr.tui.run_tui` via lazy import (preserving INV-001 of issue #3 — `ratatoskr.cli` still does not import `textual` directly; the lazy import lives inside a code path the `--send` flow never enters). Ships ONLY the chat-pane shell — design-brief §5's five side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog), the startup session picker, Tab bindings, and history rendering are deliberately deferred to follow-up issues, each in its own contract." +depends_on: + - "textual" + - "httpx" + - "ratatoskr.sse_client" + - "ratatoskr.sessions" + - "ratatoskr.cli" +used_by: [] +language: "python" +complexity: "medium" +estimated_loc: 320 +confidence: 0.8 +assumptions: + - "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) unchanged. The TUI consumes the same `sse_client` + `sessions` surfaces as issue #3; wire-level changes are insulated through those modules." + - "Textual ≥ 0.85 (per pyproject) provides `App[T]`, `App.run()`, `App.run_test()` + `Pilot`, `RichLog` widget with markdown-via-`rich.markdown.Markdown` rendering, `Input` widget with `Input.Submitted` event, declarative `BINDINGS` class attribute mapping key chords to actions, `@on()` decorator + `on_` naming convention." + - "Issues #1, #2, #3 are landed on main and their public surfaces are stable. The TUI imports `stream_turn`, `cancel_turn`, the `Event` discriminated union (`WorkerPhase | Thinking | Text | TextBoundary | ToolStart | ToolResult | Done | Error | Cancelled`), all error types from `ratatoskr.sse_client`; and `create_session`, `AgentNotFound`, `SessionApiFailed` from `ratatoskr.sessions`." + - "`httpx.AsyncClient(base_url=server_url, headers={'Authorization': f'Bearer {api_key}'})` is opened inside the App lifecycle (on_mount) and closed in on_unmount. The TUI owns its client; it does not share a client with `_amain` (the TUI path bypasses `_amain` entirely)." + - "`App.run_test()` provides a headless `Pilot` that drives the app from pytest. Pilot supports `pilot.press(...)` for key simulation and `pilot.pause()` to let pending tasks resolve. Widget queries via `app.query_one(...)` work in test mode." +open_questions: + - "Streaming-markdown partial rendering: streaming raw text mid-turn then re-rendering as Markdown on Done is the cleanest UX, but requires RichLog line-replacement (uncertain support) OR a separate `Static` for the active turn + a 'commit' on Done. Draft: stream raw text into RichLog; on Done, append a separator + the full markdown render below (acknowledging a small redundancy). If empirically ugly, refactor to Static-then-commit in a follow-up — same shape as design-brief §6's `--no-stream-formatting` punt." + - "Textual `BINDINGS` priority for `ctrl+c` vs `Input` widget focus: when `Input` is focused, does `ctrl+c` reach the app's binding or get consumed by the input widget? Draft: declare the binding with `priority=True` to ensure the app sees it regardless of focus. If `priority=True` interferes with input editing, fall back to a custom `Input` subclass that surfaces ctrl+c." + - "Should the TUI persist transcript across restarts? Per design-brief §8d ('reconnect, not resume-across-process') the answer is no — fresh transcript every launch. Confirming this is in scope of the shell contract (deferred), not punted." +prd: + issue: 4 + issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/4" + body_sha256_16: "b1e73e7d2e3dd453" + lock_in_comment_id: null + lock_in_sha256_16: null + lock_in_at: null + pinned_at: "2026-05-21T06:21:37+00:00" +dependencies: + - issue: 1 + path: "src/ratatoskr/sse_client.py" + reason: "Code-level import: TUI calls stream_turn() and cancel_turn() and renders the typed Event union." + - issue: 2 + path: "src/ratatoskr/sessions.py" + reason: "Code-level import: TUI calls create_session() and reads SessionInfo when --new is passed." + - issue: 3 + path: "src/ratatoskr/cli.py" + reason: "This issue AMENDS cli — `--send` becomes optional; when omitted, `main` lazy-imports and dispatches to `ratatoskr.tui.run_tui`. The INV-001 (no textual in cli) is preserved by the lazy-import shape." +--- + +# TUI shell — Textual app, single chat pane, two-stage Ctrl-C + +## Context + +`ratatoskr.tui` is the interactive primary presenter described in `docs/design-brief.md` §1 + §5. One `RatatoskrApp` Textual `App[int]` plus a sync `run_tui(args) -> int` entry point. The TUI is what the user gets when they invoke `ratatoskr` WITHOUT `--send` — the headline product the design brief commits to, with `--send` (issue #3) as the scriptable sibling. + +This issue ships ONLY the shell — a single chat-pane app with markdown rendering, two-stage Ctrl-C, and the session-identity-always-visible footer. Design-brief §5's five side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog), the startup session picker, Tab bindings, and history rendering are deliberately deferred to follow-up issues. Each side pane is one issue; this issue is the foundation they hang off. + +The shell is the load-bearing primary surface. Together with `--send`, it makes good on the design-brief §8b architectural claim ("share the consumer, branch the presenter"): both presenters consume the same `sse_client` + `sessions` modules without forking the API-consumption code, and the two-stage Ctrl-C semantics that `--send` cannot express (it has no idle state) come into their own here. + +## Data flow + +**Input:** +- `ParsedArgs` (from `ratatoskr.cli._parse_args`) carrying `session_id` xor `new`, `agent_id` (when `new`), `api_key`, `server_url`, `raw: bool`, and `send_content: None` (the TUI-mode marker — `--send` was omitted). +- User keypresses + Input widget submissions during the app's lifetime. + +**Output:** +- Textual screen renders (terminal escape sequences via Textual's renderer). +- Process exit code via `App.run()` return value: + - `0` — clean exit (Ctrl-C-while-idle or Ctrl-D). + - `3` — force-exit from cancelling state OR a SIGINT-before-first-event situation (mirrors `--send`'s code 3). + - `12` — `AgentNotFound` on initial `--new` session creation. + - `20` — `SessionApiFailed` on initial session creation. + - `21` — `httpx.ConnectError` / `httpx.ReadTimeout` / `httpx.TransportError` before any session was established. Mid-session network errors do NOT exit the app; they're rendered as error lines in the transcript and the input remains active for retry. + +**Side effects:** +- Outbound HTTP only (via `sessions` + `sse_client` modules). +- Terminal raw-mode + alternate-screen via Textual's lifecycle. +- One `httpx.AsyncClient` opened in `on_mount`, closed in `on_unmount`. +- One in-flight stream worker per turn (cancelled-then-replaced on the rare event-loop race; Textual's worker manager handles the bookkeeping). + +**On disk:** none. Per design-brief §8d, no cross-process resume — fresh transcript per launch. + +## Invariants + +- **INV-001 [hard]**: `ratatoskr.cli` MUST NOT import `textual` at module scope. The cli-to-tui dispatch in `main` uses a function-local `from ratatoskr.tui import run_tui` inside the branch that runs ONLY when `--send` was omitted. Verified by the existing `test_no_textual_import_in_cli` static-grep test (issue #3 INV-001), which scans `cli.py` for `import textual` / `from textual`. The `--send` path never reaches the lazy import, so the import boundary holds for scripted callers. +- **INV-002 [hard]**: The Footer widget always displays an agent slot + the LAST 8 chars of `session_id` (design-brief §4 session-identity-always-visible invariant). The format is ` · …` with a literal `·` separator and `…` prefix. The agent slot is `args.agent_id` (when `--new`), OR `SessionInfo.agent_id` (when `--new` AND a successful create_session populates it), OR the literal string `` (when `--session ` was used AND agent_id is not present in args — a `GET /sessions/{id}` lookup is out of scope for this shell, see `## Out of scope`). The `` placeholder is an ACCEPTED satisfaction of "session-identity-always-visible" — it signals to the dev that the agent is opaque from this launch but the session_id tail is still anchored. This MUST appear by the first frame after `on_mount` completes; the App MUST NOT render the chat pane in a state where the agent slot OR the session_id tail is absent. +- **INV-003 [hard]**: Two-stage Ctrl-C state machine (design-brief §8c): + - **idle state** (no turn in flight): footer hint = `"Ctrl-C twice to exit"`; first Ctrl-C → `app.exit(0)`. + - **streaming state** (turn in flight): footer hint = `"Ctrl-C to cancel"`; Ctrl-C → spawn `cancel_turn` server-side, transition to **cancelling state**. + - **cancelling state** (cancel POSTed, draining): footer hint = `"Press Ctrl-C again to exit"`; Ctrl-C → `app.exit(3)` (force-exit, abandon the in-flight drain server-side). + - After the `Cancelled` terminal event arrives (or `Done`/`Error`), state returns to **idle** and footer hint resets. + - **Note on the idle-hint discrepancy**: the idle-state hint reads `"Ctrl-C twice to exit"` but a single Ctrl-C from idle DOES exit. This is intentional per design-brief §8c's "The footer-hint state transition is load-bearing — the dev needs to see that the next Ctrl-C will exit, otherwise they hit it again expecting another cancel and lose their session." The hint is conservative-by-design — it pre-warns the dev about the *worst-case* (streaming→cancel→exit) flow rather than the literal idle case (one press exits). Implementers MUST use the literal string `"Ctrl-C twice to exit"` (NOT something more accurate like `"Ctrl-C to exit"`); changing it would diverge from the design-brief's locked UX. +- **INV-004 [hard]**: Ctrl-D is bound to `app.exit(0)` unconditionally — immediate exit regardless of state. Abandons any in-flight turn (server-side stall watchdog handles the orphan per spec). +- **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. With markdown enabled, `Text` event deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like `**hel` would render ugly), and on `Done` a separator + the full markdown-rendered assistant message is appended below the streamed deltas. **This means the assistant's response visibly appears TWICE in the transcript by design — once as the streamed raw deltas, once as the post-Done markdown render — separated by a horizontal-rule separator.** This is the v1 accepted trade-off for streaming-visibility-without-mid-stream-markdown-ugliness; the cleaner Static-then-commit pattern (streaming into a replaceable widget, then committing the markdown version in place) is documented in `open_questions:` as the follow-up if the double-display proves empirically noisy. Implementers MUST NOT attempt the Static-then-commit pattern in this shell — it's deferred. With `--raw`, only the streamed deltas appear; no post-Done re-render; no double-display. +- **INV-006 [hard]**: User-prompt echo in the transcript MUST visibly distinguish user input from assistant output. Format: `❯ ` for user lines (with a literal `❯` prefix); assistant lines have no prefix. The prefix is also a screen-reader-friendly affordance. +- **INV-007 [hard]**: One `httpx.AsyncClient` per app lifetime — opened in `on_mount`, closed in `on_unmount` via the async-with context manager pattern. The client is NOT recreated per turn (would burn the TCP connection pool). +- **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `TurnIdFlip`) during a streaming turn render as error lines in the transcript and return the app to **idle** state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes). +- **INV-009 [hard]**: No `core.*` / `worldtree.*` imports. The boundary smoke (`tests/test_no_worldtree_imports.py`) covers `src/ratatoskr/` as a whole including the new tui.py. + +## Out of scope + +- **All five side panes** (Persona, Tools, AdminEvents, BifrostState, ServerLog per design-brief §5) — each gets its own follow-up issue. The shell ships ONLY the chat pane. +- **Startup session picker** (design-brief §4 `DataTable` of `GET /sessions`) — require `--session ` OR `--new --agent ` for now; the picker is its own issue. +- **Tab bindings** (`Ctrl+1..5`) — they pair with the side panes; come later with the first side pane that needs them. +- **Multi-turn history rendering on session-attach** — `--session ` opens with a blank transcript; `GET /sessions/{id}/messages` history replay is deferred until a workflow demands it. +- **Recorded SSE snapshot fixtures** — captured by a separate follow-up issue. The TUI benefits from fixtures but doesn't author them; `--send --new` is the recording probe. +- **Cross-process resume** — per design-brief §8d, deferred to v2. Transcript is per-launch. +- **`/admin/events` SSE consumption** — admin observability surface lands with the AdminEvents pane issue. +- **`reconnect_turn` mid-session** — if a stream drops mid-turn, the TUI renders the error and returns to idle. In-process reconnect with `Last-Event-ID` resume is a separate issue (the underlying `sse_client.reconnect_turn` is implemented; the TUI doesn't invoke it yet). +- **Bifrost-binding consumer support** — not a Ratatoskr concern (per design-brief §6 negative clauses). +- **`--quiet` / `--no-stream-formatting`** — deferred per design-brief §6. Add only if streaming text + post-Done markdown render proves empirically noisy. + +## Constraints + +- **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0). The TUI is insulated from wire-level changes through `sse_client` + `sessions`. +- **[performance]** Streaming MUST NOT buffer the turn before rendering. `Text` deltas write to RichLog as they arrive. The post-Done markdown render reads the accumulated `Done.response` field from the terminal event — no client-side re-aggregation from individual deltas. +- **[security]** TUI does not log `Authorization` header, `--api-key` value, or full event bodies. Persistence is per-launch (no disk writes); transcript content is in-memory only. +- **[style]** Async-native. Textual's worker pattern (`self.run_worker(coro, exclusive=True)`) drives the stream loop; no manual thread management. `App[int]` for typed exit codes. ruff line-length=100 (per pyproject). + +## Architecture + +``` +ratatoskr [shell entry, registered in pyproject] + │ + └─ ratatoskr.cli.main(argv) [sync] + │ + ├─ _parse_args(argv) [returns ParsedArgs; --send now optional] + │ + └─ branch on args.send_content: + │ + ├─ args.send_content is not None ──► asyncio.run(_amain(args)) [issue #3 path] + │ + └─ args.send_content is None ──► from ratatoskr.tui import run_tui [lazy import] + return run_tui(args) + │ + └─ RatatoskrApp(args).run() + │ + ├─ on_mount: open AsyncClient, create_session (if --new), + │ init footer with agent_id + session_id_tail8 + ├─ on_input_submitted: spawn _stream_turn_worker(content) + │ _stream_turn_worker: + │ for event in stream_turn(...): + │ _render_event_to_log(event, log) + │ on Done: if not raw, append separator + markdown render + │ transition: streaming → idle + ├─ action_interrupt (ctrl+c): two-stage state machine per INV-003 + ├─ action_quit (ctrl+d): app.exit(0) + └─ on_unmount: close AsyncClient +``` + +--- + +```contract +FN run_tui(args: ParsedArgs) -> int +BRIEF: Sync entry point called from `ratatoskr.cli.main`'s lazy-import branch. Constructs the `RatatoskrApp` with the parsed args and runs it under Textual's loop; returns the App's exit code. +PRE: [PRE-001 hard] args is a ParsedArgs with args.send_content is None (TUI mode marker) -- assert isinstance(args, ParsedArgs) and args.send_content is None +PRE: [PRE-002 hard] exactly one of args.session_id / args.new is set -- assert bool(args.session_id) != bool(args.new) (post-_parse_args xor validation, hold-over assertion) +POST: [POST-001 return_value] returns the exit code from App.run() (0, 3, 12, 20, 21 per Data flow table) +ERROR_ROUTING: + (none at this level — App.run() catches its own exceptions and surfaces them as exit codes; uncaught errors propagate as Python exceptions to the cli's main wrapper) +STEPS: + 1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-002 + 2. [sequential, flexibility=prescriptive] Construct app = RatatoskrApp(args) + 3. [sequential, flexibility=prescriptive] RETURN app.run() — Textual's sync runner; manages its own asyncio loop +TESTS: + happy_returns_zero_on_quit [happy,tracer]: construct args with --session s-1; mock the SSE endpoint; Pilot presses ctrl+d immediately; run_tui returns 0 + precondition_send_content_none [adversarial]: args with send_content="x" → AssertionError before run() (PRE-001 catches the misuse) +``` + +```contract +CLASS RatatoskrApp(textual.app.App[int]) +BRIEF: Textual app — single chat pane shell. Holds the parsed args, the active session_id, the httpx.AsyncClient, and the Ctrl-C state machine. Exposes the bindings + the worker coordination for stream_turn / cancel_turn. +PROPERTIES: + args: ParsedArgs # passed in __init__ + session_id: str | None # set in on_mount (after create_session if --new) + agent_id: str | None # set in on_mount + client: httpx.AsyncClient | None # opened in on_mount, closed in on_unmount + state: Literal["idle", "streaming", "cancelling"] # INV-003 state machine + active_turn_id: int | None # set when first event of a turn yields; cleared on terminal + stream_worker: textual.worker.Worker | None # the current _stream_turn_worker task +BINDINGS: + - ("ctrl+c", "interrupt", "Cancel / Exit") # priority=True so Input doesn't consume it; see open_questions + - ("ctrl+d", "quit", "Exit immediately") +COMPOSE shape (declarative — implementer chooses CSS file vs inline): + Header() + RichLog(id="transcript", wrap=True, markup=True, highlight=True) + Input(id="prompt", placeholder="Type a message and press Enter") + Footer() +INV-WIRE-001: One AsyncClient lifecycle per app lifetime (INV-007). +INV-WIRE-002: state transitions strictly idle ↔ streaming ↔ cancelling per INV-003. +``` + +```contract +FN RatatoskrApp.on_mount(self) -> None +BRIEF: Lifecycle hook. Opens the httpx.AsyncClient, mints or attaches the session, populates the footer with agent_id + session_id_tail8, sets state to "idle". +PRE: [PRE-001 hard] self.client is None (on_mount fires once per app instance) -- assert self.client is None +POST: [POST-001 state_change] self.client is an open httpx.AsyncClient bound to args.server_url with the Bearer auth header +POST: [POST-002 state_change] self.session_id is non-empty (either from args.session_id or from a successful create_session) +POST: [POST-003 state_change] self.agent_id is non-empty (from args.agent_id when --new; from SessionInfo.agent_id when --session) +POST: [POST-004 side_effect] Footer subtitle shows ` · …` (INV-002 session-identity-always-visible) +POST: [POST-005 state_change] self.state == "idle"; the footer hint widget shows "Ctrl-C twice to exit" +ERROR_ROUTING: + AgentNotFound (from create_session): + local_handling: append `[agent_not_found] agent_id={exc.agent_id}` to RichLog as an error line; call self.exit(12) + flow_control: abort the mount (the app exits before user can interact) + state_recovery: none + SessionApiFailed (from create_session): + local_handling: append `[session_api_failed] status={exc.status} body={exc.body!r}` to RichLog; self.exit(20) + flow_control: abort + state_recovery: none + httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError (from create_session): + local_handling: append `[network_error] {type(exc).__name__}: {exc}` to RichLog; self.exit(21) + flow_control: abort + state_recovery: none +STEPS: + 1. [setup, flexibility=prescriptive] Validate PRE-001 + 2. [sequential, flexibility=prescriptive] Open AsyncClient: self.client = httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}) + 3. [branch, flexibility=prescriptive] IF args.new: + TRY: info = await create_session(self.client, args.agent_id) + ON AgentNotFound | SessionApiFailed | httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError: handle per ERROR_ROUTING (append + exit) + SET self.session_id = info.session_id; self.agent_id = info.agent_id + ELSE: + SET self.session_id = args.session_id; self.agent_id = args.agent_id (when args.session is used, --agent is forbidden per issue #3 INV-004 — agent_id may be empty here) + (When session_id is set but agent_id unknown: optionally GET /sessions/{id} for it; OUT OF SCOPE for this shell — display ` · …` instead. Single follow-up if it becomes noisy.) + 4. [sequential, flexibility=prescriptive] Update footer: self.sub_title = f"{self.agent_id or ''} · …{self.session_id[-8:]}"; render hint "Ctrl-C twice to exit" + 5. [sequential, flexibility=prescriptive] SET self.state = "idle" +TESTS: + happy_new_session_mount [happy,tracer]: --new --agent mimir; respx mocks POST /sessions → 201; Pilot.pause() → app.session_id is "s-new", app.agent_id is "mimir", footer text contains "mimir · …", state=="idle" + happy_existing_session_mount [happy]: --session s-1; no POST /sessions; Pilot.pause() → app.session_id is "s-1", footer shows agent (or ), state=="idle" + agent_not_found_on_mount [error]: --new; POST /sessions → 404 → app exits 12; RichLog contains "[agent_not_found]" + session_api_failed_on_mount [error]: --new; POST /sessions → 500 → app exits 20; RichLog contains "[session_api_failed]" + network_error_on_mount [error]: --new; POST /sessions raises httpx.ConnectError → app exits 21; RichLog contains "[network_error]" + footer_identity_visible_first_frame [trace]: INV-002 — after Pilot.pause(), query the footer and assert the agent_id + session_id_tail8 substring is present before any other interaction + client_open_after_mount [trace]: post-mount self.client is not None and is_closed is False +``` + +```contract +FN RatatoskrApp.on_input_submitted(self, event: Input.Submitted) -> None +BRIEF: Handler for the Input widget's Submitted event (user pressed Enter). Echoes the user's prompt with the `❯ ` prefix (INV-006), clears the input field, and spawns a worker to stream the turn. When the app is not idle, the submission is discarded WITH a visible transcript notice (NOT silently) — surprises the user less than a silent swallow. +PRE: [PRE-001 hard] event.input.id == "prompt" (the prompt Input widget) -- if event.input.id != "prompt": return (Textual routes by id; assert is defensive) +PRE: [PRE-002 hard] self.state == "idle" — submissions during streaming/cancelling are discarded with a visible notice (INV-003) -- if self.state != "idle": write "[busy] turn in flight; input ignored" to RichLog; event.input.value = ""; return +POST: [POST-001 side_effect] on idle submit: RichLog contains a new line "❯ {content}" (INV-006 user-prompt format) +POST: [POST-002 side_effect] on idle submit: event.input.value == "" (input cleared) +POST: [POST-003 state_change] on idle submit: self.state transitions to "streaming"; self.stream_worker is the live Worker for this turn +POST: [POST-004 side_effect] on idle submit: footer hint updates to "Ctrl-C to cancel" +POST: [POST-005 side_effect] on non-idle submit: RichLog contains "[busy] turn in flight; input ignored"; event.input.value == ""; state unchanged; NO new worker spawned +STEPS: + 1. [setup, flexibility=prescriptive] IF event.input.id != "prompt": RETURN + IF self.state != "idle": + Append "[busy] turn in flight; input ignored" to RichLog # visible notice, not silent swallow + Clear event.input.value + RETURN + 2. [sequential, flexibility=prescriptive] content = event.input.value.strip(); IF NOT content: RETURN (don't send empty messages) + 3. [sequential, flexibility=prescriptive] Append "❯ {content}" to RichLog + 4. [sequential, flexibility=prescriptive] Clear event.input.value + 5. [sequential, flexibility=prescriptive] SET self.state = "streaming"; update footer hint to "Ctrl-C to cancel" + 6. [sequential, flexibility=prescriptive] SET self.stream_worker = self.run_worker(self._stream_turn_worker(content), exclusive=True) +TESTS: + happy_submit_echoes_and_spawns [happy,tracer]: state=idle; Pilot types "hello" + Enter; RichLog contains "❯ hello"; input cleared; state=="streaming"; stream_worker is not None + empty_submit_no_op [trace]: type "" + Enter → no change to RichLog; no worker spawned; state stays idle + submit_during_streaming_shows_busy_notice [adversarial]: state=streaming; Pilot types "x" + Enter → RichLog contains "[busy] turn in flight; input ignored"; input cleared; no new worker; only the original worker is alive; state stays "streaming" + submit_during_cancelling_shows_busy_notice [adversarial]: state=cancelling; Pilot types "x" + Enter → same as above; state stays "cancelling" + footer_hint_flips_to_cancel [trace]: after submit, footer hint shows "Ctrl-C to cancel" +``` + +```contract +FN RatatoskrApp._stream_turn_worker(self, content: str) -> None +BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via `_render_event_to_log`, captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error). +PRE: [PRE-001 hard] self.state == "streaming" (set by on_input_submitted before spawn) -- assert self.state == "streaming" +PRE: [PRE-002 hard] self.client is not None (set in on_mount) -- assert self.client is not None +PRE: [PRE-003 hard] content is non-empty (caller validated in on_input_submitted) -- assert content +POST: [POST-001 state_change] after terminal event OR error, self.state == "idle"; self.active_turn_id is None; footer hint reset to "Ctrl-C twice to exit" +POST: [POST-002 side_effect] each event passed through _render_event_to_log exactly once (until terminal OR until cancel-induced abort) +POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005) +POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt) +ERROR_ROUTING: + SseConnectFailed | SseConnectionDropped | MalformedSseId | TurnIdFlip: + local_handling: append `[