Clears the two ✗ FAIL (missing STEPS) the v2.1 parser surfaced. #3: faithful STEPS for CliPresenterState.render, _format_duration_ms, _format_usage (the two formatters also gain PRE/POST from their real asserts). render STEPS enumerate AffectUpdate + AwaitingLlmFirstToken as demoted telemetry (Worldtree #204/#201), extending POST-005 beyond the issue #12 set. #4: refresh the TUI presenter contract from the abandoned single-RichLog double-display model to the shipped four-pane live-Markdown model (v0.5.0-v0.14.0 + Worldtree #201/#204). Rewrites TuiPresenterState.render and _stream_turn_worker (signature, POSTs, STEPS, TESTS), INV-005, the [performance] constraint, the COMPOSE sketch, the CLASS block (BRIEF/PROPERTIES/INV-WIRE-002), the resolved open_question, and the _cancel_via_sse call site. Verified against src/ratatoskr/tui.py and the real test names in tests/test_tui.py. Both contracts: 0 validation errors (pre-existing multi-tracer warnings on _run_turn / action_interrupt left untouched).
60 KiB
contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
| contract_version | target_module | scope | depends_on | used_by | language | complexity | estimated_loc | confidence | assumptions | open_questions | prd | dependencies | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2.1 | ratatoskr.tui | 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. |
|
python | medium | 320 | 0.8 |
|
|
|
|
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(fromratatoskr.cli._parse_args) carryingsession_idxornew,agent_id(whennew),api_key,server_url,raw: bool, andsend_content: None(the TUI-mode marker —--sendwas 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—AgentNotFoundon initial--newsession creation.20—SessionApiFailedon initial session creation.21—httpx.ConnectError/httpx.ReadTimeout/httpx.TransportErrorbefore 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_clientmodules). - Terminal raw-mode + alternate-screen via Textual's lifecycle.
- One
httpx.AsyncClientopened inon_mount, closed inon_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.cliMUST NOT importtextualat module scope. The cli-to-tui dispatch inmainuses a function-localfrom ratatoskr.tui import run_tuiinside the branch that runs ONLY when--sendwas omitted. Verified by the existingtest_no_textual_import_in_clistatic-grep test (issue #3 INV-001), which scanscli.pyforimport textual/from textual. The--sendpath 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_idvia a dedicatedStatic(id="identity")widget composed adjacent toFooter()(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 isargs.agent_id(when--new), ORSessionInfo.agent_id(when--newAND a successful create_session populates it), OR the literal string<unknown>(when--session <id>was used AND agent_id is not present in args — aGET /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 afteron_mountcompletes; 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 → spawncancel_turnserver-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
Cancelledterminal event arrives (orDone/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.
- idle state (no turn in flight): footer hint =
- 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;
--rawis the opt-out.Textevent deltas accumulate in the presenter'stext_chunk_bufferand render LIVE asMarkdown(buffer)into a single responseStatic(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-Donedouble-display, and its#current-textdock-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 aMarkdownRenderable — 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.AsyncClientper app lifetime — opened byrun_tui'sasync withBEFOREApp.run_async()is entered and closed by the sameasync withAFTERApp.run_async()returns (per issue #6 INV-002). The App is a consumer of an externally-owned client; it MUST NOT callself.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) coverssrc/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
DataTableofGET /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}/messageshistory 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 --newis the recording probe. - Cross-process resume — per design-brief §8d, deferred to v2. Transcript is per-launch.
/admin/eventsSSE consumption — admin observability surface lands with the AdminEvents pane issue.reconnect_turnmid-session — if a stream drops mid-turn, the TUI renders the error and returns to idle. In-process reconnect withLast-Event-IDresume is a separate issue (the underlyingsse_client.reconnect_turnis 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 throughsse_client+sessions. - [performance] Streaming MUST NOT block on the full turn before rendering.
Textdeltas append to the presenter'stext_chunk_bufferand re-render the responseStaticin place on each delta (live Markdown) — the transcript updates as tokens arrive. The displayed response is built delta-by-delta;Done.responseis observable but is NOT the source of the rendered output. - [security] TUI does not log
Authorizationheader,--api-keyvalue, 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)
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)
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`.)
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.
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
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"
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
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.
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)
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
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)
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)
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:
parser.add_argument("--send", required=True)→parser.add_argument("--send", default=None).send_contentbecomesstr | None.- The non-empty-content check now ONLY fires when
--sendwas passed:if ns.send is not None and not ns.send: raise UsageError("--send content must be non-empty"). --rawflag added:parser.add_argument("--raw", action="store_true"). Maps toParsedArgs.raw: bool.
ParsedArgs amendments:
send_content: str | None(wasstr).- 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_sendtest (which asserted UsageError when --send is missing) is REMOVED —--sendis now optional. A new testtest_no_send_dispatches_to_tuiasserts that_parse_args(["--session", "s-1", "--api-key", "k"])returnsParsedArgs(send_content=None, ...)without raising. - A new test in TestMain asserts that
main(["--session", "s-1", "--api-key", "k"])callsrun_tui(monkeypatched) rather than_amain. - The
--rawflag gets coverage:_parse_argstest that--rawsetsargs.raw=Trueand 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.