---
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_<event>` 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 `<agent_slot> · …<session_id_tail8>` 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 `<unknown>` (when `--session <id>` 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 `<unknown>` 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: `❯ <content>` 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 <id>` OR `--new --agent <id>` 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 <id>` 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 <args>                                 [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": <agent>, "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 `<agent_id> · …<session_id[-8:]>` (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 "<unknown>"; 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 · …<tail>"
  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 "<unknown> · …<tail>" (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 `[<label>] <details>` as an `error-label` Static into the transcript scroll (mirrors cli.py's error labels)
    flow_control: abort (the iteration aborts; the finally-block restores state)
    state_recovery: finally → state idle; active_turn_id cleared; hint reset. (INV-008: mid-session wire errors do NOT exit the app.)
  asyncio.CancelledError (from action_interrupt force-exit OR Worker.cancel()):
    local_handling: none — propagate to let Textual's worker manager clean up
    flow_control: abort
    state_recovery: finally → state idle; active_turn_id cleared; hint reset. (cancel_task was already spawned by action_interrupt.)
STEPS:
  1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
  2. [setup, flexibility=prescriptive] Query the four panes — transcript=#transcript-scroll (VerticalScroll), tools_log=#tools-log, debug_log=#debug-log, thinking_log=#thinking-log — and construct presenter = TuiPresenterState()
  3. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
       IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id; CALL self._write_turn_headers(self.active_turn_id)   # POST-003
       CALL presenter.render(event, transcript=transcript, tools_log=tools_log, debug_log=debug_log, thinking_log=thinking_log, raw=self.args.raw, on_persona_snapshot=self._update_persona_surfaces)   # POST-002
       IF isinstance(event, (Done, Error, Cancelled)): BREAK   # terminal; presenter already rendered the live Markdown + tinted label (POST-004 — no re-render)
     CATCH SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip as exc:
       AUDIT the failure; mount `[<label>] <details>` as an error-label Static into transcript
  4. [cleanup, flexibility=prescriptive] FINALLY:
       CALL self._transition("idle", "worker_finally"); SET self.active_turn_id = None; CALL self._set_hint(self.HINT_IDLE)
TESTS:
  happy_text_done_renders_markdown [happy,tracer]: mock yields text("hello") + done(response="hello"); after Pilot.pause(), RichLog contains "hello" (the streamed delta) AND below it a separator + the markdown render of "hello"; state → idle
  raw_flag_skips_markdown_render [trace]: --raw; mock yields text + done; RichLog has the streamed delta but NO separator + markdown re-render
  error_terminal_returns_to_idle [happy]: mock yields text + error → RichLog has "[error]" label; state → idle (NOT app exit per INV-008)
  cancelled_terminal_returns_to_idle [happy]: mock yields text + cancelled → "[cancelled]" label; state → idle
  active_turn_id_set_on_first_event [trace]: mock yields text(42:1) then waits; after first render, self.active_turn_id == 42 (verifies the cancel path can pick it up)
  sse_connect_failed_returns_to_idle [error]: mock returns 404 → "[sse_connect_failed]" label in RichLog; state → idle; app does NOT exit (INV-008)
  connection_dropped_returns_to_idle [error]: mock raises RemoteProtocolError mid-stream → "[connection_dropped]" label; state → idle
  malformed_sse_data_returns_to_idle [error,issue#7]: mock yields text + event with `data: not-json` → "[malformed_sse_data]" label; state → idle; app does NOT exit (INV-008)
  rendered_event_per_event [trace]: spy on TuiPresenterState.render (issue #12 amendment: was _render_event_to_log); mock yields N events; call_count == N
```

```contract
CLASS TuiPresenterState                                              # issue #12 amendment; refreshed to the four-pane live-Markdown model (v0.5.0–v0.14.0 + Worldtree #201/#204)
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Routes each event across four panes (transcript / tools_log / debug_log / thinking_log): Thinking deltas coalesce by `\n` into `thinking_log` wrapped in Rule(start)/Rule(end) per run; Text deltas accumulate in `text_chunk_buffer` and render live as `Markdown(buffer)` into a single in-place-updated response `Static` (no post-Done re-render); demoted telemetry gets a `· ` dim prefix (WorkerPhase/TextBoundary → debug_log, Tool* → tools_log); terminal events mount a tinted label + write a turn-summary to debug_log; AffectUpdate fires the persona callback; AwaitingLlmFirstToken mounts/updates a heartbeat indicator; render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
PROPERTIES:
  thinking_open: bool
  thinking_run_index: int
  thinking_chunk_buffer: str
  text_chunk_buffer: str
  current_response_widget: object        # the live response Static; None between turns
  text_delta_count: int
  text_byte_count: int
  thinking_delta_count: int
  thinking_byte_count: int
  turn_start_ts: float
  awaiting_widget: object                # the awaiting-token indicator Static; None when closed
  heartbeat_count: int
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (issue #12 INV-008).
INV-WIRE-002: Thinking is single-view (v0.7.1+): deltas coalesce by `\n` into `thinking_log` (RichLog), each run wrapped in Rule(start)/Rule(end). The issue #12 two-views `#thinking-current` Static was removed.
```

```contract
FN TuiPresenterState.render(self, event: Event, *, transcript: VerticalScroll, tools_log: RichLog, debug_log: RichLog, thinking_log: RichLog, raw: bool, on_persona_snapshot: object = None) -> None   # issue #12 amendment; refreshed to the four-pane model (v0.5.0–v0.14.0 + Worldtree #201/#204)
BRIEF: Render one Worldtree SSE event into the four-pane TUI with editorial hierarchy, thinking/text coalescing, live Markdown, persona + heartbeat surfaces, and an INV-009 render-exception fallback. Unicode allowed (`·` U+00B7 demotion prefix, `→` U+2192 usage arrow). Pane routing — `transcript` (VerticalScroll) = chat content (live-Markdown response Static, tinted terminal labels, awaiting-token indicator); `thinking_log` (RichLog) = coalesced Thinking deltas wrapped in Rule(start)/Rule(end); `tools_log` (RichLog) = ToolStart + ToolResult; `debug_log` (RichLog) = per-event audit line + WorkerPhase + TextBoundary + turn-summary. Optional `on_persona_snapshot` callback fires when AffectUpdate carries a snapshot (issue #13 / Worldtree #204). Supersedes the issue #12 single-`log`/`thinking_widget` model and the post-Done Markdown re-render (both removed at v0.5.0/v0.9.0).
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, AwaitingLlmFirstToken))
POST: [POST-001 side_effect] audit bookkeeping (v0.10.0): Text increments text_delta_count/text_byte_count, Thinking increments thinking_delta_count/thinking_byte_count (each sets turn_start_ts on its first delta) — neither emits a per-delta audit line (token-rate spam control); every other event sets turn_start_ts if unset AND writes one dimmed `_audit_line(event)` to debug_log
POST: [POST-002 side_effect] for AffectUpdate (Worldtree #204): audit line per POST-001, then IF snapshot is not None AND on_persona_snapshot is provided, invoke on_persona_snapshot(snapshot) with callback exceptions swallowed (persona surface failure must not break the stream); RETURN
POST: [POST-003 side_effect] for AwaitingLlmFirstToken (Worldtree #201): heartbeat_count++; first heartbeat mounts a dimmed "awaiting first token · {s:.1f}s" Static into transcript, subsequent heartbeats update it in place; widget-op exceptions swallowed; scroll_end; RETURN
POST: [POST-004 side_effect] gap-close: any non-heartbeat event past the heartbeat branch removes the awaiting indicator if still mounted (awaiting_widget → None)
POST: [POST-005 side_effect] for Thinking: open the run on first delta (thinking_run_index++, write Rule("turn {turn_id} · thinking #{idx} start") to thinking_log, thinking_open=True); accumulate content into thinking_chunk_buffer; flush each complete `\n`-terminated line to thinking_log (skip blank lines), retain the tail; RETURN
POST: [POST-006 side_effect] for non-Thinking when thinking_open: flush the buffered tail to thinking_log, write Rule("turn {turn_id} · thinking #{idx} end"), thinking_open=False; THEN render the new event
POST: [POST-007 side_effect] for Text: append content to text_chunk_buffer; render `text_chunk_buffer if raw else Markdown(text_chunk_buffer)` — first Text delta mounts a `.response-md` Static into transcript, subsequent deltas update it in place (live Markdown, no post-Done re-render); scroll_end; RETURN
POST: [POST-008 side_effect] for Done/Error/Cancelled: write a dimmed turn-summary (turn_id, text_deltas/bytes, thinking_deltas/bytes, heartbeats, elapsed_ms) to debug_log; clear text_chunk_buffer + current_response_widget; mount a tinted terminal-label Static into transcript — Done = success-tinted `[done] turn_id=... model=... duration={_format_duration_ms} usage {_format_usage(arrow='→')}`, Error = error-tinted `[error] turn_id=... code=... message=...!r`, Cancelled = warning-tinted `[cancelled] turn_id=... reason=...!r partial_message_id=...`; scroll_end; RETURN
POST: [POST-009 side_effect] for demoted telemetry: WorkerPhase + TextBoundary → dimmed `· <label>: <fields>` to debug_log; ToolStart + ToolResult → dimmed `· <label>: <fields>` to tools_log (ToolResult result truncated to 200 chars) per issue #13 INV-014; RETURN
POST: [POST-010 exception] never propagates; on any internal exception, write `_plain_label(event)` + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause) to the event's pane (tools_log for Tool*; thinking_log for Thinking; debug_log for WorkerPhase/TextBoundary; else mount Statics into transcript)
ERROR_ROUTING:
  Exception (any internal render failure — widget op, formatting, persona callback):
    local_handling: write `_plain_label(event)` + `[render_error] {type(exc).__name__}` (no message — INV-009 security clause) to the event's pane (tools_log for Tool*; thinking_log for Thinking; debug_log for WorkerPhase/TextBoundary; else mount Statics into transcript)
    flow_control: skip (swallow — render never propagates)
    state_recovery: none (the next event renders against fresh state)
STEPS:
  1. [setup, flexibility=prescriptive] Validate event ∈ Event union per PRE-001.
  2. [setup, flexibility=prescriptive] Enter the render try-block — steps 3..11 run inside it; step 12 is the INV-009 fallback.
  3. [branch, flexibility=prescriptive] Audit bookkeeping (POST-001):
       IF Text: set turn_start_ts on first delta; text_delta_count++; text_byte_count += len(content)
       ELIF Thinking: set turn_start_ts on first delta; thinking_delta_count++; thinking_byte_count += len(content)
       ELSE: set turn_start_ts if unset; WRITE _dim(_audit_line(event)) to debug_log
  4. [branch, flexibility=prescriptive] IF AffectUpdate (POST-002): IF snapshot is not None AND on_persona_snapshot is not None: TRY on_persona_snapshot(snapshot) / swallow Exception; RETURN
  5. [branch, flexibility=prescriptive] IF AwaitingLlmFirstToken (POST-003): heartbeat_count++; secs = elapsed_ms_since_building_prompt / 1000; mount-or-update a dimmed "awaiting first token · {secs:.1f}s" Static in transcript (swallow widget Exception); scroll_end; RETURN
  6. [branch, flexibility=prescriptive] Gap-close (POST-004): IF awaiting_widget is not None: remove it (swallow Exception); SET awaiting_widget=None
  7. [branch, flexibility=prescriptive] IF Thinking (POST-005): IF NOT thinking_open: thinking_run_index++; WRITE Rule(start) to thinking_log; thinking_open=True. APPEND content to thinking_chunk_buffer; WHILE "\n" in buffer: partition on "\n", WRITE non-empty line to thinking_log, keep the remainder. RETURN
  8. [branch, flexibility=prescriptive] Close open thinking run (POST-006): IF thinking_open: IF buffer non-empty: WRITE buffer tail to thinking_log, clear buffer. WRITE Rule(end) to thinking_log; thinking_open=False
  9. [branch, flexibility=prescriptive] IF Text (POST-007): APPEND content to text_chunk_buffer; rendered = buffer if raw else Markdown(buffer); IF current_response_widget is None: mount Static(rendered, classes="response-md") in transcript; ELSE: current_response_widget.update(rendered); scroll_end; RETURN
  10. [branch, flexibility=prescriptive] IF Done|Error|Cancelled (POST-008): elapsed_ms = int((monotonic()-turn_start_ts)*1000) if turn_start_ts else 0; WRITE dimmed turn-summary to debug_log; clear text_chunk_buffer + current_response_widget; mount the tinted terminal-label Static (Done=success / Error=error / Cancelled=warning) in transcript with the documented label text; scroll_end; RETURN
  11. [branch, flexibility=prescriptive] Demoted telemetry (POST-009), then RETURN: WorkerPhase → debug_log `· worker_phase: ...`; ToolStart → tools_log `· tool_start: ...`; ToolResult → tools_log `· tool_result: ... result={result!r:.200}`; TextBoundary → debug_log `· text_boundary: ...`
  12. [error_handler, flexibility=prescriptive] EXCEPT Exception as exc (POST-010 / INV-009): WRITE _plain_label(event) + "[render_error] {type(exc).__name__}" (no message) to the event's pane per ERROR_ROUTING
TESTS:
  text_then_done_mounts_widget_and_finalizes [happy,tracer]: Text + Done (NOT raw) → live Markdown `.response-md` widget mounted; on Done the widget ref clears + a success-tinted [done] label mounts; no post-Done re-render (no double-print)
  thinking_coalesces_until_newline [happy]: Thinking deltas buffer; only complete `\n`-terminated lines flush to thinking_log
  thinking_flushes_on_newline [happy]: a Thinking delta containing `\n` flushes the completed line and retains the tail for the next delta
  thinking_closes_to_thinking_log [happy]: 2× Thinking + WorkerPhase → tail flushed + Rule(end) closes the run in thinking_log; thinking_open=False
  multiple_thinking_runs_each_get_thinking_log_section [scenario]: Thinking → Text → Thinking → Done → TWO Rule-wrapped thinking sections
  cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → run closes with Rule(end); warning-tinted [cancelled] label mounted
  text_first_delta_mounts_response_widget [happy]: first Text delta mounts a `.response-md` Static in transcript holding Markdown(buffer)
  text_subsequent_deltas_update_in_place [trace]: later Text deltas update the same widget (live Markdown), no new mount
  raw_flag_skips_markdown [trace]: raw=True → response widget holds plain str, no Markdown wrapping
  worker_phase_demoted_to_debug_log [trace]: WorkerPhase → dimmed `· worker_phase:` in debug_log, not transcript
  tool_start_routes_to_tools_log [trace]: ToolStart → `· tool_start:` in tools_log (issue #13 INV-014)
  tool_result_routes_to_tools_log [trace]: ToolResult → `· tool_result: ... result=<≤200 chars>` in tools_log
  worker_phase_emits_audit_line [trace]: a non-Text/Thinking event writes one dimmed `_audit_line` to debug_log
  tool_start_emits_audit_line [trace]: ToolStart writes an audit line to debug_log in addition to the tools_log routing
  text_delta_counted_not_per_event_audit_line [trace]: Text deltas increment counters but emit NO per-delta audit line (token-rate spam control)
  done_emits_turn_summary_line [trace]: Done writes a dimmed turn-summary (text/thinking delta+byte counts, heartbeats, elapsed_ms) to debug_log before clearing counters
  affect_update_routes_to_audit_only [scenario]: AffectUpdate(snapshot) → audit line + on_persona_snapshot(snapshot) callback; no transcript mount
  affect_update_scheduled_has_no_pad_detail [trace]: AffectUpdate(status="scheduled", snapshot=None) → audit line only; callback skipped
  awaiting_llm_first_token_mounts_indicator [scenario]: first AwaitingLlmFirstToken mounts a dimmed "awaiting first token · {s}s" Static in transcript
  awaiting_subsequent_heartbeats_update_in_place [trace]: later heartbeats update the same indicator in place; heartbeat_count grows
  awaiting_indicator_removed_when_gap_closes [scenario]: the first non-heartbeat event removes the awaiting indicator (gap closed)
  render_exception_fallback [adversarial]: an internal render failure writes `_plain_label` + `[render_error] <ClassName>` (NO message) to the event's pane; never propagates (INV-009)
  state_reset_per_worker [trace]: a fresh TuiPresenterState() starts with thinking_open=False and zeroed counters
  duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s"
  usage_format_unicode_arrow [trace]: Done → "usage ... in → ... out (...)" (Unicode arrow, not ASCII)
```

```contract
FN RatatoskrApp.action_interrupt(self) -> None
BRIEF: Ctrl-C handler (declared in BINDINGS). Implements the INV-003 two-stage state machine. Idle → exit(0); streaming → cancel_turn + transition to cancelling; cancelling → exit(3) (force-exit, abandon drain).
PRE: [PRE-001 hard] self.state is one of ("idle", "streaming", "cancelling") -- assert self.state in ("idle", "streaming", "cancelling")
POST: [POST-001 side_effect] state transitions follow INV-003 (idle→exit, streaming→cancelling, cancelling→exit)
POST: [POST-002 side_effect] when transitioning streaming→cancelling, exactly one POST /sessions/{id}/turns/{active_turn_id}/cancel was issued (via a spawned _cancel_via_sse worker)
POST: [POST-003 side_effect] when transitioning streaming→cancelling, footer hint updates to "Press Ctrl-C again to exit"
ERROR_ROUTING:
  CancelFailed | CancelTurnNotFound | CancelAlreadyCompleted (during streaming→cancelling cancel POST):
    local_handling: append `[cancel_failed] <details>` to RichLog (handled inside _cancel_via_sse)
    flow_control: skip — INV-009 of issue #3 carried forward; the in-flight stream is still draining and will reach its own terminal
    state_recovery: state stays "cancelling" until the stream-worker terminates; then the worker's finally block restores idle
STEPS:
  1. [setup, flexibility=prescriptive] Validate PRE-001
  2. [branch, flexibility=prescriptive]
     IF self.state == "idle":
       self.exit(0)
     ELIF self.state == "streaming":
       IF self.active_turn_id is None:
         # First event hadn't arrived yet — no cancel possible. Force-cancel the worker and exit non-zero.
         IF self.stream_worker is not None: self.stream_worker.cancel()
         self.exit(3)
         RETURN
       SET self.state = "cancelling"
       update footer hint to "Press Ctrl-C again to exit"
       self.run_worker(_cancel_via_sse(self.client, self.session_id, self.active_turn_id, transcript=self.query_one("#transcript-scroll", VerticalScroll), audit=self._audit))
     ELIF self.state == "cancelling":
       # Second Ctrl-C — force exit, abandon drain
       IF self.stream_worker is not None: self.stream_worker.cancel()
       self.exit(3)
TESTS:
  idle_ctrl_c_exits_zero [happy,tracer]: state=idle; Pilot.press("ctrl+c") → app.exit was called with code 0
  streaming_first_ctrl_c_cancels [scenario,tracer]: state=streaming; active_turn_id=42; Pilot.press("ctrl+c"); state → cancelling; respx tracked one POST /sessions/{id}/turns/42/cancel; footer hint shows "Press Ctrl-C again to exit"
  streaming_no_turn_id_force_exits [scenario]: state=streaming; active_turn_id is None (first event hadn't arrived); Pilot.press("ctrl+c") → app.exit(3); worker cancelled; no cancel_turn POST
  cancelling_second_ctrl_c_force_exits [scenario]: state=cancelling; Pilot.press("ctrl+c") → app.exit(3); worker cancelled
  cancel_failed_swallowed [scenario]: state=streaming; active_turn_id=42; cancel POST returns 500 → "[cancel_failed]" in RichLog; state stays cancelling until stream-worker terminates
```

```contract
FN RatatoskrApp.action_quit(self) -> None
BRIEF: Ctrl-D handler (declared in BINDINGS). Immediate exit regardless of state. Abandons any in-flight turn server-side (the spec's stall watchdog handles the orphan).
PRE: (none — always callable)
POST: [POST-001 side_effect] self.exit(0) was called
POST: [POST-002 side_effect] if self.stream_worker is alive, it was cancelled (cleanup; the asyncio task and the AsyncClient are cleaned up by Textual's lifecycle / on_unmount regardless)
STEPS:
  1. [sequential, flexibility=prescriptive] IF self.stream_worker is not None AND not self.stream_worker.is_finished: self.stream_worker.cancel()
  2. [sequential, flexibility=prescriptive] self.exit(0)
TESTS:
  idle_ctrl_d_exits_zero [happy,tracer]: state=idle; Pilot.press("ctrl+d") → exit(0)
  streaming_ctrl_d_force_exits [scenario]: state=streaming; Pilot.press("ctrl+d") → exit(0); worker cancelled; no cancel_turn POST attempted (Ctrl-D is the abandon-and-exit path)
```

```contract
FN RatatoskrApp.on_unmount(self) -> None
BRIEF: Lifecycle hook. Per issue #6 INV-002: NARROWED to a no-op. The client lifetime is managed by `run_tui`'s `async with`, NOT by this hook — calling `self.client.aclose()` here would close the client while `_resolve_then_run`'s async-with still holds it.
PRE: (none)
POST: (none — no side effects)
STEPS:
  1. [sequential, flexibility=prescriptive] return None
TESTS:
  # unmount_closes_client → moved to run_tui_closes_client_on_app_exit at the
  # _resolve_then_run layer per issue #6: the close site is the async-with
  # exiting AFTER app.run_async returns, not on_unmount.
  (none — this hook is intentionally empty; coverage lives at the _resolve_then_run layer)
```

```contract
FN _cancel_via_sse(client: httpx.AsyncClient, session_id: str, turn_id: int, *, log: RichLog) -> None
BRIEF: TUI's analog of cli's `_cancel_and_log` — calls `sse_client.cancel_turn`, swallows all exceptions per INV-009 of issue #3, writes `[cancel_failed]` to the RichLog on failure. Used as a fire-and-forget worker spawned by `action_interrupt`.
PRE: [PRE-001 hard] client is not None
PRE: [PRE-002 hard] turn_id is a positive int
POST: [POST-001 side_effect] exactly one POST /sessions/{id}/turns/{turn_id}/cancel was issued
POST: [POST-002 side_effect] on cancel exception, log received a "[cancel_failed]" line
POST: [POST-003 exception] never raises
ERROR_ROUTING:
  CancelFailed | CancelTurnNotFound | CancelAlreadyCompleted | httpx.RequestError:
    local_handling: log.write(f"[cancel_failed] {type(exc).__name__}: {exc}")
    flow_control: skip (swallow)
    state_recovery: none
STEPS:
  1. [setup, flexibility=prescriptive] Validate PRE
  2. [sequential, flexibility=prescriptive] TRY: await cancel_turn(client, session_id, turn_id)
     CATCH the routed exceptions: log the labeled line; return None
TESTS:
  happy_cancel [happy,tracer]: mock returns 200 → returns None; log has no [cancel_failed]
  cancel_failed_500 [error]: mock returns 500 → log has "[cancel_failed] CancelFailed: ..."; no exception escapes
  cancel_already_completed [scenario]: mock returns 409 → "[cancel_failed] CancelAlreadyCompleted:"; no exception escapes
  transport_error_swallowed [error]: respx raises httpx.ConnectError → "[cancel_failed] ConnectError"; no exception escapes
```

---

## CLI amendments (issue #3 contract concurrent amendment)

This issue also amends `ratatoskr.cli` to make `--send` optional and to dispatch to the TUI when omitted. The amendments are small and pinned here so reviewers see them in one place. They land in the same commit as the TUI implementation.

**`_parse_args` amendments:**

1. `parser.add_argument("--send", required=True)` → `parser.add_argument("--send", default=None)`. `send_content` becomes `str | None`.
2. The non-empty-content check now ONLY fires when `--send` was passed: `if ns.send is not None and not ns.send: raise UsageError("--send content must be non-empty")`.
3. `--raw` flag added: `parser.add_argument("--raw", action="store_true")`. Maps to `ParsedArgs.raw: bool`.

**`ParsedArgs` amendments:**

- `send_content: str | None` (was `str`).
- New field: `raw: bool` (default False).

**`main` amendments:**

```
ON args.send_content is None:
  # TUI mode — 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))
```

**Test amendments to issue #3's TESTS:**

- The existing `test_usage_no_send` test (which asserted UsageError when --send is missing) is REMOVED — `--send` is now optional. A new test `test_no_send_dispatches_to_tui` asserts that `_parse_args(["--session", "s-1", "--api-key", "k"])` returns `ParsedArgs(send_content=None, ...)` without raising.
- A new test in TestMain asserts that `main(["--session", "s-1", "--api-key", "k"])` calls `run_tui` (monkeypatched) rather than `_amain`.
- The `--raw` flag gets coverage: `_parse_args` test that `--raw` sets `args.raw=True` and absence leaves it False.

The contract for issue #3 is amended IN-PLACE to reflect these changes (the FN signatures + ERROR_ROUTING + TESTS for `_parse_args` and `main`). Both contracts MUST be drift-check clean after this issue's commits land.
