contract(issue#4): author ratatoskr.tui shell + amend issue #3 cli

Issue #4: Textual TUI shell — the interactive primary presenter
(design-brief §1, §5). Single chat-pane App[int] subclass + sync
run_tui(args) entry. Composes existing sessions + sse_client modules
(no forked API-consumption code, per design-brief §8b).

Six FN blocks: run_tui, RatatoskrApp class + on_mount + on_unmount,
on_input_submitted, _stream_turn_worker, _render_event_to_log,
action_interrupt, action_quit, _cancel_via_sse.

Nine hard invariants codifying:
- INV-001: lazy-import boundary so cli.py STILL doesn't import textual
  at module scope (issue #3's INV-001 carried forward)
- INV-002: session-identity-always-visible footer (`<agent> · …<tail8>`)
  with explicit `<unknown>` carve-out for --session without --agent
- INV-003: two-stage Ctrl-C state machine (idle/streaming/cancelling)
  per design-brief §8c
- INV-005: markdown default-on with --raw opt-out; deliberately produces
  streaming-deltas + post-Done markdown re-render (accepted v1 trade-off,
  Static-then-commit refactor deferred)
- INV-007: one AsyncClient per app lifetime
- INV-008: mid-session errors → idle (don't exit); only initial
  session-create errors exit

Concurrent in-place amendment of issue #3's contract:
- --send becomes optional; when omitted, send_content=None is the
  TUI-mode marker
- --raw flag added to ParsedArgs
- main dispatches via lazy `from ratatoskr.tui import run_tui` when
  send_content is None
- _parse_args + main TESTS sections updated (no_send_marks_tui_mode
  replaces usage_no_send; new raw_flag_default_false / raw_flag_set /
  no_send_dispatches_to_tui)

Volva paraphrase round on issue #4: 5 findings, all amended.
(1) INV-002 `<unknown>` carve-out wording.
(2) Idle "Ctrl-C twice to exit" hint kept per design-brief §8c's
    conservative-by-design rationale; INV-003 spells out the
    intentional one-press-from-idle discrepancy.
(3) Markdown double-render trade-off made explicit in INV-005.
(4) Submit-during-streaming now writes `[busy] turn in flight; input
    ignored` (visible notice, not silent swallow).
(5) `{!r:.200}` format spec kept with explanatory inline comment.

Both contracts drift-check clean. prd: pinned to issue #4 body SHA
b1e73e7d2e3dd453 at 2026-05-21T06:21:37+00:00.
This commit is contained in:
vh
2026-05-21 00:31:15 -07:00
parent 9717fb80e2
commit 9d469d5c67
2 changed files with 478 additions and 5 deletions
+16 -5
View File
@@ -201,14 +201,19 @@ STEPS:
RETURN 11
ON SystemExit as exc:
RETURN int(exc.code) if exc.code is not None else 0
2. [sequential, flexibility=prescriptive] RETURN asyncio.run(_amain(args))
2. [branch, flexibility=prescriptive] IF args.send_content is None:
# TUI mode (issue #4 amendment) — lazy import preserves INV-001 (no textual in cli at module scope)
FROM ratatoskr.tui IMPORT run_tui
RETURN run_tui(args)
ELSE:
RETURN asyncio.run(_amain(args))
TESTS:
happy_returns_amain_exit_code [happy,tracer]: argv specifies a complete --send invocation; monkeypatch _amain to return 0 → main returns 0
usage_error_no_send [error]: argv=[] → main returns 10; stderr has "[usage_error]"; _amain never called
usage_error_both_session_and_new [error]: argv has both --session and --new → returns 10; stderr "[usage_error]"
auth_error_missing_key [error]: argv specifies --send/--new/--agent but neither --api-key nor WORLDTREE_API_KEY is set → returns 11; stderr "[auth_error]"; _amain never called
no_argv_uses_sys_argv [trace]: argv=None → _parse_args is called with sys.argv[1:] (monkeypatched argparse capture confirms)
help_exits_cleanly [happy]: argv=["--help"] → main returns 0 (or whatever code argparse exits with); _amain never called; help text was printed to stdout by argparse
no_send_dispatches_to_tui [happy]: argv omits --send → main lazy-imports run_tui and calls it (NOT _amain); send_content marker is None (issue #4 amendment)
```
```contract
@@ -245,16 +250,19 @@ ERROR_ROUTING:
state_recovery: none (no resources acquired before _parse_args)
STEPS:
1. [setup, flexibility=prescriptive] Construct argparse.ArgumentParser:
--send <content> (required, str, non-empty)
--send <content> (str, OPTIONAL — issue #4 amendment: omitted → TUI mode marker; if passed, must be non-empty)
--session <id> (str, optional)
--new (bool flag, default False)
--agent <id> (str, optional — required-with-validation in step 3)
--api-key <key> (str, optional — env fallback in step 4)
--server <url> (str, optional — env fallback + default in step 5)
--raw (bool flag, default False — issue #4 amendment: TUI markdown opt-out)
2. [sequential, flexibility=prescriptive] Parse argv:
TRY: ns = parser.parse_args(argv if argv is not None else sys.argv[1:])
ON SystemExit:
Re-raise as UsageError with argparse's captured message
2a. [branch, flexibility=prescriptive] IF ns.send is not None AND not ns.send:
RAISE UsageError("--send content must be non-empty") # empty-string --send still invalid
3. [branch, flexibility=prescriptive] Validate session/new/agent triad per INV-004:
IF ns.session and ns.new: RAISE UsageError("--session and --new are mutually exclusive")
IF NOT ns.session AND NOT ns.new: RAISE UsageError("pass exactly one of --session or --new")
@@ -266,12 +274,13 @@ STEPS:
5. [sequential, flexibility=prescriptive] Resolve server_url per INV-006:
server_url = ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
6. [cleanup] RETURN ParsedArgs(
send_content=ns.send,
send_content=ns.send, # may be None (TUI marker, issue #4 amendment)
session_id=ns.session,
new=ns.new,
agent_id=ns.agent,
api_key=api_key,
server_url=server_url,
raw=ns.raw, # issue #4 amendment
)
TESTS:
happy_new [happy,tracer]: argv=["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs(send_content="hi", session_id=None, new=True, agent_id="mimir", api_key="k", server_url="http://localhost:8000")
@@ -281,7 +290,9 @@ TESTS:
server_default [trace]: argv omits --server and WORLDTREE_API_URL is unset → ParsedArgs.server_url=="http://localhost:8000"
server_env_fallback [trace]: monkeypatch WORLDTREE_API_URL="http://t.local:9000"; argv omits --server → ParsedArgs.server_url=="http://t.local:9000"
server_flag_beats_env [trace]: monkeypatch WORLDTREE_API_URL="env"; argv has --server "flag" → ParsedArgs.server_url=="flag"
usage_no_send [error]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → UsageError (argparse required-flag)
no_send_marks_tui_mode [happy]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs.send_content=None (TUI marker; issue #4 amendment — was UsageError pre-#4)
raw_flag_default_false [trace]: --raw absent → ParsedArgs.raw==False (issue #4 amendment)
raw_flag_set [trace]: --raw → ParsedArgs.raw==True (issue #4 amendment)
usage_both_session_and_new [adversarial]: argv has both --session and --new → UsageError("mutually exclusive")
usage_neither_session_nor_new [adversarial]: argv has --send but neither --session nor --new → UsageError("pass exactly one")
usage_new_without_agent [adversarial]: argv=["--send","hi","--new","--api-key","k"] → UsageError("--agent is required when --new")