--- 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: - "RESOLVED (v0.9.0): streaming-markdown partial rendering. Shipped the Static-then-commit pattern — `Text` deltas accumulate in `text_chunk_buffer` and re-render `Markdown(buffer)` in place into a single response `Static`; no post-Done re-render, no double-display. The issue #12 draft's stream-raw-then-re-render-on-`Done` approach (and its `#current-text` dock-bottom Static) was dropped because the dock-bottom growth visually overlapped the transcript. See INV-005." - "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 visible Footer-area UI always displays an agent slot + the LAST 8 chars of `session_id` via a dedicated `Static(id="identity")` widget composed adjacent to `Footer()` (Textual's built-in Footer renders BINDINGS descriptions and doesn't naturally accept custom content; a sibling Static carries the identity string in the same visual region). The session-identity-always-visible invariant is design-brief §4. 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. `Text` event deltas accumulate in the presenter's `text_chunk_buffer` and render LIVE as `Markdown(buffer)` into a single response `Static` (CSS class `.response-md`) mounted in the transcript scroll — the first delta mounts the widget, each subsequent delta updates it in place. There is NO post-Done re-render and NO double-display: the streamed-then-committed Markdown is the one and only rendering of the response. (v0.9.0 shipped exactly the Static-then-commit pattern the issue #12 draft had deferred; the earlier stream-raw-then-re-render-on-`Done` double-display, and its `#current-text` dock-bottom Static, were removed because the dock-bottom growth visually overlapped the transcript.) With `--raw`, the same widget holds the plain accumulated text instead of a `Markdown` Renderable — still live, still single-display, no Markdown wrapping. - **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 by `run_tui`'s `async with` BEFORE `App.run_async()` is entered and closed by the same `async with` AFTER `App.run_async()` returns (per issue #6 INV-002). The App is a consumer of an externally-owned client; it MUST NOT call `self.client.aclose()`. The client is NOT recreated per turn (would burn the TCP connection pool). - **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `MalformedSseData` (issue #7), `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 the live Markdown stream 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 block on the full turn before rendering. `Text` deltas append to the presenter's `text_chunk_buffer` and re-render the response `Static` in place on each delta (live Markdown) — the transcript updates as tokens arrive. The displayed response is built delta-by-delta; `Done.response` is observable but is NOT the source of the rendered output. - **[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) │ └─ asyncio.run(_resolve_then_run(args)) [issue #6 amendment] │ └─ async with httpx.AsyncClient(...) as client: │ ├─ pre-flight session resolve (--new → create_session, │ else attach args.session_id). Errors → real stderr, │ return appropriate exit code (12 / 20 / 21) BEFORE │ the alt-screen opens (issue #6 INV-001 / INV-006). │ ├─ app = RatatoskrApp(args, session_id=..., agent_id=..., │ client=client) └─ await app.run_async(): │ ├─ on_mount: populate identity widget from pre- │ resolved state; state=idle ├─ 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 │ transition: streaming → idle ├─ action_interrupt (ctrl+c): two-stage state machine ├─ action_quit (ctrl+d): app.exit(0) └─ on_unmount: no-op (client closed by async-with above) ``` --- ```contract FN run_tui(args: ParsedArgs) -> int BRIEF: Sync entry point called from `ratatoskr.cli.main`'s lazy-import branch. Thin sync wrapper around `asyncio.run(_resolve_then_run(args))` — per issue #6, the actual work (AsyncClient open, pre-flight session resolution with stderr error routing, then App.run_async) happens inside the single async helper so everything runs in one event loop. 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 _resolve_then_run (0, 3, 12, 20, 21 per Data flow table) ERROR_ROUTING: (none at this level — _resolve_then_run handles all 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] RETURN asyncio.run(_resolve_then_run(args)) TESTS: happy_returns_zero_on_quit [happy,tracer]: construct args with --session s-1; monkeypatch RatatoskrApp.run_async to capture invocation and return 0; run_tui returns 0; the captured app was constructed with the passed args (verifies run_tui correctly wraps app.run_async via _resolve_then_run). precondition_send_content_none [adversarial]: args with send_content="x" → AssertionError before any asyncio.run (PRE-001 catches the misuse) ``` ```contract FN _resolve_then_run(args: ParsedArgs) -> int # NEW per issue #6 ASYNC: yes BRIEF: Pre-flight session resolution then App.run_async() inside one event loop. Errors at this layer (AgentNotFound, SessionApiFailed, network errors) print to `sys.stderr` (the operator's real terminal) and short-circuit BEFORE the alt-screen opens (issue #6 INV-001). The label format + exit codes match `ratatoskr.cli._amain`'s verbatim (issue #6 INV-006), so operators see one vocabulary across `--send` and TUI modes. Owns the AsyncClient lifecycle via `async with` (issue #6 INV-002). PRE: [PRE-001 hard] args is a ParsedArgs with args.send_content is None POST: [POST-001 return_value] returns exit code (12 on AgentNotFound, 20 on SessionApiFailed, 21 on network error, OR the App's run_async return value) POST: [POST-002 side_effect] the AsyncClient is opened BEFORE create_session and closed AFTER app.run_async returns (or after the error-routed return) ERROR_ROUTING: AgentNotFound (from create_session): local_handling: sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n") flow_control: return 12 — alt-screen never opens state_recovery: none SessionApiFailed (from create_session): local_handling: sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") flow_control: return 20 — alt-screen never opens state_recovery: none httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError (from create_session): local_handling: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") flow_control: return 21 — alt-screen never opens state_recovery: none STEPS: 1. [setup, flexibility=prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) via `async with` — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint) 2. [branch, flexibility=prescriptive] IF args.new: TRY: info = await create_session(client, args.agent_id, end_user_id=args.end_user_id) # issue #5: thread end_user_id when set; None preserves pre-#5 body shape ON AgentNotFound | SessionApiFailed | httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError: handle per ERROR_ROUTING (stderr + return code) SET session_id = info.session_id; agent_id = info.agent_id ELSE: SET session_id = args.session_id; agent_id = args.agent_id # may be None — INV-002 carve-out preserved 3. [sequential, flexibility=prescriptive] Construct app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client) 4. [sequential, flexibility=prescriptive] exit_code = await app.run_async() — Textual's async-runner; same event loop as the AsyncClient 5. [cleanup, flexibility=prescriptive] RETURN exit_code or 0 TESTS: alt_screen_never_opens_on_resolve_error [trace]: monkeypatch RatatoskrApp.run_async to a sentinel that fails if called; respx → 404 from POST /sessions; assert run_tui returns 12; assert the sentinel was NEVER invoked (probes issue #6 INV-001). agent_not_found_on_resolve [error]: --new + 404 → run_tui returns 12; capsys.readouterr().err contains "[agent_not_found]" + "agent_id=mimir". session_api_failed_on_resolve [error]: --new + 500 → run_tui returns 20; capsys stderr contains "[session_api_failed]" + "status=500". network_error_on_resolve [error]: --new + httpx.ConnectError → run_tui returns 21; capsys stderr contains "[network_error]" + "ConnectError". stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce identical stderr lines for AgentNotFound (probes issue #6 INV-006). Drive each path with respx → 404 and capsys-capture both stderr outputs; assert string equality and "[agent_not_found] agent_id=mimir\n" verbatim. client_open_after_resolve [trace]: monkeypatch RatatoskrApp.run_async to capture self.client; assert client is not None and client.is_closed is False AT the moment run_async executes (probes that the App receives a live, open client from _resolve_then_run). client_lifetime_owned_by_run_tui [trace]: monkeypatch RatatoskrApp.run_async to capture self.client and confirm it's open during run; after run_tui returns, assert the captured client.is_closed is True (probes issue #6 INV-002 — the async-with closes the client AFTER run_async, not on_unmount). run_tui_closes_client_on_app_exit: same as client_lifetime_owned_by_run_tui — the async-with in _resolve_then_run is the closing site. happy_new_with_end_user_id_resolve [happy, issue #5]: args.end_user_id="alice" → POST /sessions outbound body == {"agent_id": , "end_user_id": "alice"}. (Issue #5's contract names this `_mount`; post-#6 the equivalent site is `_resolve_then_run`.) ``` ```contract CLASS RatatoskrApp(textual.app.App[int]) BRIEF: Textual app — single chat pane shell. Consumer of an externally-owned `httpx.AsyncClient` (passed at __init__ per issue #6 INV-002). Holds the parsed args, the pre-resolved session_id + agent_id, the client reference, and the Ctrl-C state machine. Exposes the bindings + the worker coordination for stream_turn / cancel_turn. __init__: `def __init__(self, args: ParsedArgs, *, session_id: str, agent_id: str | None, client: httpx.AsyncClient) -> None` — per issue #6 INV-003, session_id and client are REQUIRED at construction; the App no longer mints anything in on_mount. PROPERTIES: args: ParsedArgs # passed in __init__ session_id: str # passed in __init__ (pre-resolved by _resolve_then_run; issue #6) agent_id: str | None # passed in __init__ (may be None for --session without --agent; INV-002 carve-out) client: httpx.AsyncClient # passed in __init__; lifecycle owned by run_tui's async-with (INV-007 amended) 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; exact tab/CSS layout lives in tui.py.compose): Header() Horizontal: VerticalScroll(id="transcript-scroll") # chat content: per-turn Static widgets mounted dynamically by the presenter — prompt echo, live-Markdown response (.response-md), tinted terminal labels, awaiting-token indicator. No single RichLog; wire-error labels mount as error-label Statics here. TabbedContent (right column; Ctrl+1..3 switch tabs): RichLog(id="tools-log", markup=False) # ToolStart / ToolResult RichLog(id="debug-log", markup=False) # per-event audit line + WorkerPhase + TextBoundary + turn-summary RichLog(id="thinking-log", markup=False) # coalesced Thinking deltas, Rule(start)/Rule(end) per run Input(id="prompt", placeholder="Type a message and press Enter") Static("", id="identity") # INV-002: visible session-identity strip; rendered by on_mount Static(HINT_IDLE, id="hint") # INV-003: visible Ctrl-C state hint; updated on state transitions Footer() INV-WIRE-001: AsyncClient lifetime owned by run_tui's async-with (INV-007 amended; issue #6 INV-002). INV-WIRE-002: state transitions strictly idle ↔ streaming ↔ cancelling per INV-003. ``` ```contract FN RatatoskrApp.on_mount(self) -> None BRIEF: Lifecycle hook. Per issue #6: NARROWED to identity-widget population from pre-resolved state. No more session-create branch (moved to _resolve_then_run); no more client-open (moved to run_tui's async-with). Just populates the identity widget + sets idle state. PRE: [PRE-001 hard] self.client is not None (set in __init__ from _resolve_then_run; issue #6 INV-003) -- assert self.client is not None PRE: [PRE-002 hard] self.session_id is not None (set in __init__ from _resolve_then_run) -- assert self.session_id is not None POST: [POST-001 side_effect] Footer subtitle shows ` · …` (INV-002 session-identity-always-visible) POST: [POST-002 side_effect] The #identity Static widget renders the same identity string POST: [POST-003 state_change] self.state == "idle"; the #hint Static widget shows "Ctrl-C twice to exit" ERROR_ROUTING: (none — session-resolution errors are handled at the _resolve_then_run layer BEFORE on_mount can be reached; on_mount is now error-free per issue #6 INV-001) STEPS: 1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-002 2. [sequential, flexibility=prescriptive] Compute identity: agent_slot = self.agent_id or ""; identity = f"{agent_slot} · …{self.session_id[-8:]}" 3. [sequential, flexibility=prescriptive] Populate widgets: self.sub_title = identity (Header subtitle mirror); self.query_one("#identity", Static).update(identity) 4. [sequential, flexibility=prescriptive] SET self.state = "idle"; update #hint widget to HINT_IDLE ("Ctrl-C twice to exit") TESTS: happy_new_session_mount [happy,tracer]: construct via _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir"); Pilot.pause() → app.session_id == "s-new12345", app.agent_id == "mimir", state == "idle", footer contains "mimir · …" happy_existing_session_mount [happy]: construct via _resolved_app(_args_existing(session_id="s-existing-tail8x")); args.agent_id is None → app.agent_id is None → identity shows " · …" (INV-002 carve-out) footer_identity_visible_first_frame [trace]: INV-002 — after Pilot.pause(), the #identity Static widget renders the agent_slot + session_id_tail8 substring before any other interaction. # Error tests moved to the _resolve_then_run TESTS block per issue #6: # agent_not_found_on_mount → agent_not_found_on_resolve (capsys stderr) # session_api_failed_on_mount → session_api_failed_on_resolve # network_error_on_mount → network_error_on_resolve # client_open_after_mount → client_open_after_resolve ``` ```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` (exclusive). Queries the four panes, constructs a fresh `TuiPresenterState`, drives `stream_turn`, and renders each event through `presenter.render`. Captures `active_turn_id` + writes the turn headers on the first event (for the Ctrl-C cancel path), breaks on the terminal event, mounts wire-error labels as `error-label` Statics into the transcript scroll, and a `finally` always transitions state back to "idle". v0.9.0: rendering is live (the presenter streams Markdown in place) — there is NO post-Done re-render here. 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] the `finally` always transitions to "idle": self.state == "idle"; self.active_turn_id is None; footer hint reset to HINT_IDLE — on terminal event, mid-session wire error, OR cancellation POST: [POST-002 side_effect] each event is passed through TuiPresenterState.render exactly once (four panes + the on_persona_snapshot callback threaded), until the terminal event OR a cancel-induced abort POST: [POST-003 state_change] on the FIRST yielded event: active_turn_id is set to event.sse_id.turn_id AND _write_turn_headers(active_turn_id) mounts the turn header (active_turn_id is read by action_interrupt for cancel_turn) POST: [POST-004 side_effect] no post-Done Markdown re-render — the presenter renders Markdown live during Text streaming (v0.9.0); the worker only breaks on the terminal event after the presenter has mounted the tinted label ERROR_ROUTING: SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip: local_handling: audit the failure, then mount `[