Files
ratatoskr/docs/contracts/issues/12.contract.md
T
vh 3b9c610587 feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
Replaces the stateless _render_event / _render_event_to_log helpers with
stateful per-turn presenters (CliPresenterState / TuiPresenterState).
Coalesces thinking-event deltas into a single growing display per run;
demotes telemetry events with editorial hierarchy; formats duration +
usage for human reading. Headline behavior change: a 50-token thinking
phase now renders as ONE coalesced growing line in CLI (or one closed
RichLog entry + per-delta live Static widget in TUI), not 50 lines of
[thinking] spam.

Editorial promotion line (issue #12 INV-002):
- Load-bearing (no demotion prefix): Text, Done, Error, Cancelled
- Demoted telemetry (`. ` ASCII prefix in CLI; dim `· ` in TUI):
  WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult

Stateful coalescing:
- Thinking deltas accumulate into thinking_buffer; first non-thinking
  event closes the run with a single \n boundary in CLI / one closed
  dim RichLog entry in TUI.
- TUI adds a dedicated Static(id="thinking-current") widget that shows
  the last ~200 chars of the active run, mirroring per-delta updates.
  Two-views-of-thinking decoupling per INV-004: chronological RichLog +
  always-visible widget.
- CLI INV-005: when stdout text was streamed mid-line,
  text_written_since_newline triggers a stdout flush + \n before the
  next stderr terminal label — guarantees [done] / [error] / [cancelled]
  land on their own line in a TTY without breaking pipe-to-file
  scripted consumers.

Formatting helpers (issue #12 INV-006 / INV-007):
- _format_duration_ms — autoscale `347ms` / `5.5s` / `1.2m`
- _format_usage — natural-language `6756 in -> 126 out (6882 total, 0
  cached)` with arrow="->" CLI / "→" TUI

Cross-frontier design pass (eitri-smithy-dev, althing
01KSBE52YZR5E3SPTKA672JE43) returned 16-of-16 confirmed decisions + 4
material divergences applied:
- ASCII `. ` prefix in CLI (`·` is U+00B7, not ASCII)
- RichLog one-closed-entry-per-run + Static per-delta updates (not
  inline-mirror as initially proposed)
- presenter-state object instead of pure-function rendering
- Framed as "contract semantics amendment", not "polish"

Volva paraphrase round (5 prose-precision fixes applied to
12.contract.md): INV-001 "growing display" semantics; single hide
mechanism for the Static widget (Textual reactive `display: bool`);
[render_error] security clause (type-only, no exception message);
text_written_since_newline `\n`-terminated text corner case;
[create_session] integration path (bypasses state.render — not an SSE
Event variant).

Volva code-review round (5 findings applied):
- F1 drift: render-exception fallback now writes BOTH a plain-label
  fallback line for the original event AND the `[render_error] <type>`
  line (was missing the fallback half).
- F2 drift: dim Rich style applied to all demoted-telemetry RichLog
  writes via `rich.text.Text(..., style="dim")` (was plain str).
- F3 drift: belt-and-braces widget clear+hide on EVERY terminal event
  (Done/Error/Cancelled), even when thinking_open was False.
- F4 precision: _format_usage gains PRE-001 assertion on the four
  expected usage keys.
- F5 precision: _run_turn signature amended in issue #3 contract to
  document the new `state: CliPresenterState | None = None` test-
  injection kwarg.

[create_session] lifecycle line demoted to `. create_session:` (written
directly by _amain; bypasses state.render since it's not a wire-level
SSE Event variant). Pre-amendment _render_event / _render_event_to_log
and their test classes removed under the no-backwards-compat rule.

Issues #3 and #4 contracts amended in-place: #3 (CliPresenterState
CLASS + FN block + helper FN blocks + _run_turn signature + _amain
create_session demotion); #4 (TuiPresenterState CLASS + FN block +
compose Static widget + _stream_turn_worker state construction).

209 tests GREEN; ruff clean. Bumps v0.1.0 → v0.2.0 (minor — output
shape change breaks pre-amendment grep patterns like `[thinking] '`;
no public API surface change beyond the rendering contract).

Persistent-memory commit-along: captures the issue #12 decision,
forward direction (require end_user_id for every access — declined
worldtree-dev's requires_end_user_id offer because we'll send it
universally), and the Heimdall scope-model foot-gun note (the
"per-Tier-1-agent scope add" diagnosis was a phantom ask resolved by
worldtree-dev's correction; agent.call:* baseline covers all Tier 1).
2026-05-23 16:13:55 -07:00

38 KiB
Raw Blame History

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.cli + ratatoskr.tui Presenter contract semantics amendment — replace stateless 'one labeled line per non-Text event' rendering with stateful event coalescing + visual hierarchy. Both presenters gain a small renderer-state object that owns `thinking_buffer` / `thinking_open` / `text_written_since_newline`. Thinking deltas coalesce into a single growing run, closed on the first non-thinking event. Demoted-telemetry events (`WorkerPhase`, `Thinking`, `TextBoundary`, `ToolStart`, `ToolResult`) get visual demotion: `. ` prefix on stderr in CLI (ASCII), dim style + `· ` prefix in TUI's RichLog. Load-bearing events (`Text`, `Done`, `Error`, `Cancelled`) keep no demotion prefix. TUI adds a dedicated `Static(id='thinking-current')` widget for live per-delta updates alongside the RichLog's chronological one-closed-entry-per-run. `duration_ms` auto-scales (`347ms` / `5.5s` / `1.2m`); `usage` renders as natural-language flow with ASCII arrow in CLI, Unicode arrow in TUI. CLI's `[done]` / `[error]` / `[cancelled]` labels get a stdout-flush + newline-boundary guarantee. No new public flags; no wire-level surface change. Two parallel in-place amendments to issues #3 (cli) and #4 (tui), landing in one commit at v0.2.0. Mission refinement (per persistent-memory amendment): 'the observability surface presented at the right zoom level for the operator's task' — raw per-event streaming is the wrong zoom for debugging; coalesced thinking + demoted telemetry is the right zoom.
ratatoskr.sse_client
python medium 220 0.85
Worldtree event sequencing per the v0.19.0 spec pin is `Thinking* WorkerPhase Text* (ToolStart ToolResult)* Done`, with WorkerPhase and Text optionally interleaved across thinking runs (e.g., `Thinking* WorkerPhase Text* Thinking* Text* Done`). The closure trigger 'first non-thinking event' is correct for every interleaving pattern in the spec; multiple thinking runs each get their own coalesced display.
The presenter-state object is a small per-turn-spawned dataclass-style structure, not a long-lived singleton. CLI: new state per `_amain` call. TUI: new state per `_stream_turn_worker` invocation (one per turn). Reset semantics fall out of construction — no explicit reset method needed.
TUI's `Static(id='thinking-current')` widget is composed once at app startup (during `compose()`); the widget is hidden by setting `widget.display = False` AFTER the compose pass (in `on_mount`, or constructed with `display=False` if the Static constructor supports it — implementer's mechanical call). Visibility toggle uses Textual's reactive `Widget.display: bool` attribute throughout (`widget.display = True` to show, `widget.display = False` to hide). The contract does NOT use CSS `classes='hidden'` or direct `widget.styles.display` mutation — those are equivalent in effect but mixing them in one contract creates implementation-spec churn. Clear + hide happens on the turn-terminal event (`Done` / `Error` / `Cancelled`).
CLI stdout/stderr split stays intact: stdout = LLM text content; stderr = everything else. The TTY-interleave problem is solved by `text_written_since_newline` state — when about to render a terminal label, if text has been written, flush a `\n` to stdout first. Scripted consumers piping `--send '...' > out.txt` are not affected.
Per-delta flush in CLI (~50× per turn for a long thinking phase) is acceptable. Real-time observability requires per-delta visibility; batch-buffering adds latency without meaningful CPU savings.
ASCII-only prefixes in CLI output (`. `, `->`) for scriptability across non-UTF8 terminals + log redirects. TUI may use Unicode (`· `, `→`) since it already commits to UTF-8 terminal assumptions via Textual.
No new CLI flags. The renderer's choices ARE the product. `--quiet` / `--debug` / `--verbose-events` toggle complexity is rejected until a concrete scripted-caller use case demonstrates real demand.
Editorial promotion line is stable: load-bearing = `Text`, `Done`, `Error`, `Cancelled` (the model's voice + terminal outcomes); demoted = `WorkerPhase`, `Thinking`, `TextBoundary`, `ToolStart`, `ToolResult` (streaming telemetry + tool activity). Tool events are debug-important but visually compete with assistant text; a future Tools pane (design-brief §5) may promote tool details there without changing this transcript contract.
Cross-frontier design pass with eitri-smithy-dev (althing thread 01KSBE52YZR5E3SPTKA672JE43, 2026-05-23) confirmed 16 of 16 originally proposed decisions. 4 material divergences applied: (a) `·` is U+00B7 not ASCII, use `. ` in CLI; (b) RichLog gets one closed-entry-per-run + Static gets live per-delta updates (not inline-mirror); (c) presenter-state object instead of stateless functions; (d) frame as 'contract semantics amendment' not 'polish'.
Should we eventually expose a `--debug` flag that renders the pre-v0.2.0 raw-event-per-line shape for the case where coalescing hides something? Draft: no — `git checkout v0.1.0` is the escape hatch until a concrete use case demands the flag. Defer to follow-up issue if it surfaces.
Should the TUI `Static(id='thinking-current')` widget use Rich markup for emphasis (italic-dim) or stay plain text? Draft: plain text for v0.2.0; revisit if visual hierarchy proves insufficient. Rich markup inside Static requires `markup=True` which then needs careful escape handling for user-supplied content.
Should `ToolResult.result` get smarter truncation (parse-aware for known shapes vs the current `{!r:.200}` repr-truncate)? Draft: no for this pass — same truncation behavior as today; the demotion + prefix change is the visual fix. Smart truncation is a separate Tools-pane-shaped concern.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
12 #12 9e5daa500cc3df85 null null null 2026-05-23T22:13:28+00:00
issue path reason
3 src/ratatoskr/cli.py In-place contract amendment: `_render_event` becomes `CliPresenterState.render` (method on a new small state class); `_amain` constructs the state and threads it through `_run_turn`; existing per-event TESTS restructured around state transitions; new multi-event-sequence tests added for coalesce closure + stdout/stderr newline-boundary.
issue path reason
4 src/ratatoskr/tui.py In-place contract amendment: `_render_event_to_log` becomes `TuiPresenterState.render` (method on a new small state class); `RatatoskrApp.compose()` gains a new `Static(id='thinking-current')` widget; `_stream_turn_worker` constructs the presenter state per turn and threads it through; existing TESTS restructured; new tests added for the two-views-of-thinking decoupling + Static widget lifecycle.

Presenter contract semantics amendment — stateful event coalescing + visual hierarchy

Context

A 2026-05-23 mimir smoke against personal Worldtree (post-v0.1.0 commit 804c2df) exposed that the current presenters (cli._render_event and tui._render_event_to_log) render every Worldtree SSE event as a labeled line, with no visual hierarchy and no coalescing. Concrete problems observed:

  1. Thinking deltas spam. Worldtree streams thinking as token-deltas (just like text). The current renderer writes one [thinking] '<token>' line per delta. A 50-token thinking phase = 50 lines.
  2. text_boundary fires inline mid-text, visually breaking sentences.
  3. worker_phase renders with the same visual weight as actual model output, drowning load-bearing signal.
  4. ToolStart / ToolResult share the telemetry-vs-transcript tension.
  5. duration_ms / usage are unformatted (duration_ms=5467, usage={'prompt_tokens': 6756, ...}).
  6. [done] on stderr interleaves with stdout text in a TTY because there's no newline-boundary guarantee between the two streams.

Per persistent-memory: "the product IS the observability surface; chat is the input mechanism." Suppressing events is the wrong direction. The right direction is rendering them cleanly, with editorial judgment about what's load-bearing transcript vs demoted telemetry.

This amendment refines the mission slightly: "the observability surface presented at the right zoom level for the operator's task." Per-event raw streaming is the wrong zoom for normal debugging; coalesced thinking

  • demoted telemetry is the right zoom. Design pass was cross-frontier consulted with eitri-smithy-dev (althing thread 01KSBE52YZR5E3SPTKA672JE43); 16 of 16 original decisions confirmed with 4 material divergences applied (see assumptions).

Data flow

Input: unchanged. Same Event discriminated union from ratatoskr.sse_client (WorkerPhase | Thinking | Text | TextBoundary | ToolStart | ToolResult | Done | Error | Cancelled).

Output change (both presenters):

  • Thinking deltas: instead of one line per delta, single growing run closed on the first non-thinking event.
  • Demoted telemetry (WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult): visual demotion — . prefix in CLI stderr, dim style
    • · prefix in TUI RichLog.
  • Load-bearing terminal events (Done, Error, Cancelled): no demotion prefix; CLI guarantees a \n to stdout BEFORE the label is written to stderr when text has been written this turn.
  • duration_ms formats: 347ms / 5.5s / 1.2m autoscale.
  • usage formats: 6756 in -> 126 out (6882 total, 0 cached) ASCII arrow in CLI; Unicode arrow in TUI.

Output change (TUI-only):

  • New Static(id="thinking-current") widget composed in RatatoskrApp.compose(). Hidden by default. Updated per Thinking delta with the last ~200 chars of accumulated thinking. Hidden + cleared on turn-terminal event.
  • RichLog receives ONE closed thinking entry per thinking-run (not per-delta). Static widget gets per-delta updates.

Side effects: none new. Same outbound HTTP, same SSE consumption. No persistence.

Invariants

  • INV-001 [hard]: Thinking coalescing — every consecutive run of Thinking events MUST render as a single growing display. The CLI emits MULTIPLE writes to stderr (one . thinking: prefix on the first delta of the run, then one write per subsequent delta with no intervening \n), composing ONE logical stderr line that is terminated by a single \n written at closure. The TUI emits per-delta updates to the thinking-current Static widget AND, at closure, ONE RichLog entry containing the full accumulated run content. The closure trigger is the first non-thinking event arrival, including terminal events (Done / Error / Cancelled). Multiple thinking runs in one turn each get their own coalesced display; the state machine resets on closure and re-opens on the next Thinking event.

  • INV-002 [hard]: Editorial promotion is fixed: Load-bearing: Text, Done, Error, Cancelled (the model's voice + terminal outcomes — no demotion prefix). Demoted telemetry: WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult (streaming telemetry + tool activity — demotion prefix in CLI and TUI). Adding or moving an event variant between the two groups is a contract change.

  • INV-003 [hard]: Demotion visual treatment:

    • CLI: . prefix on every demoted-event stderr line. ASCII-only (no · Unicode, no ANSI dim). Load-bearing events get no prefix.
    • TUI: dim Rich style + · prefix on demoted-event RichLog entries. Load-bearing events get no demotion.
  • INV-004 [hard]: TUI two-views-of-thinking:

    • The dedicated Static(id="thinking-current") widget receives per-delta updates showing the last ~200 chars of the current accumulated thinking-run content ( prefix when truncated).
    • The RichLog transcript receives ONE closed entry per thinking-run written at closure time, content = the full accumulated thinking text (dim, · prefix).
    • The two views are decoupled: per-delta widget update fires for every Thinking event; per-run RichLog write fires once on closure. No per-delta RichLog writes for Thinking events.
  • INV-005 [hard]: CLI stdout/stderr newline boundary — before writing any load-bearing terminal label ([done], [error], [cancelled]) to stderr, the renderer state MUST check text_written_since_newline; if true, write \n to stdout and flush, then reset the flag, then write the terminal label to stderr. This guarantees the terminal label lands on its own line below the assistant text in a TTY. (Scripted consumers piping stdout to a file see the same \n cleanly terminating the text.)

  • INV-006 [hard]: Duration formatting via shared helper _format_duration_ms(ms: int) -> str:

    • ms < 1000"347ms"
    • 1000 ≤ ms < 60_000"5.5s" (one decimal)
    • ms ≥ 60_000"1.2m" (one decimal)
  • INV-007 [hard]: Usage formatting via shared helper _format_usage(usage: dict, *, arrow: str) -> str: Input: {"prompt_tokens": int, "completion_tokens": int, "total_tokens": int, "cached_input_tokens": int}. Output: f"{p} in {arrow} {c} out ({t} total, {ci} cached)". arrow="->" in CLI (ASCII), arrow="→" in TUI (Unicode).

  • INV-008 [hard]: Presenter-state lifecycle:

    • One CliPresenterState instance constructed per _amain call; discarded on return.
    • One TuiPresenterState instance constructed per _stream_turn_worker invocation (one per turn); discarded when the worker exits (success, error, or cancellation).
    • No long-lived singleton; no cross-turn state in either presenter.
  • INV-009 [hard]: TUI render-exception fallback — if any exception fires inside TuiPresenterState.render() (e.g., the thinking-current widget reference goes stale during teardown), the exception MUST be caught at the presenter boundary; the original event MUST still be rendered as a plain labeled RichLog line (the pre-amendment behavior); a visible [render_error] <type> line MUST be written to the RichLog so the operator sees the degradation. Format is the exception class name ONLY — NOT the exception message / args — because exception payloads can contain wire data from the original event (e.g., AttributeError's repr of the event object). The [security] constraint trumps the readability win of including <msg>; the operator can attach a debugger or read logs if the bare <type> doesn't pinpoint the cause. Silent-swallow remains forbidden.

  • INV-010 [hard]: No new public CLI flags. --quiet, --verbose-events, --debug, --no-thinking are out of scope. The renderer choices ARE the product. Adding a flag is a separate contract amendment with its own demand-evidence.

  • INV-011 [hard]: No core.* / worldtree.* imports (existing boundary; unchanged). No new third-party deps. Existing dependencies on httpx, httpx-sse, textual, rich cover the surface.

Out of scope

  • --quiet / --debug / --verbose flags. Per INV-010.
  • Side-pane work (AdminEvents, Persona, Tools, BifrostState, ServerLog per design-brief §5). The dedicated TUI thinking widget added here is the foundation but is NOT a "Persona pane" or any other named §5 pane. Future side-panes may relocate thinking rendering; this amendment fixes the current chat-pane shell.
  • i18n / locale-aware formatting. English-only Vuong-only. 5.5s is locale-blind by design.
  • Cross-process resume / transcript persistence. Per design-brief §8d, deferred to v2.
  • Smart truncation of ToolResult.result beyond the existing {!r:.200} repr-truncate. Same shape as today; the demotion + prefix change is the visual fix. Smart per-tool-result-shape truncation is a Tools-pane-shaped concern.
  • Replay of pre-amendment behavior via a --legacy-render flag. git checkout v0.1.0 is the escape hatch.

Constraints

  • [compatibility] Spec pin unchanged. The wire surface (Event union from ratatoskr.sse_client) is unchanged; only the rendering of those events changes.
  • [performance] Per-delta flush in CLI is acceptable (real-time observability requires it). The state object is a small dataclass; per-render overhead is negligible relative to the SSE stream cost.
  • [security] Same as today — no logged credentials. The [render_error] fallback (INV-009) MUST NOT include exception-payload content that could leak request data.
  • [style] Ruff line-length=100. ASCII-only in CLI presenter output; Unicode allowed in TUI. Type-hinted PresenterState classes with dataclass(slots=True) for memory efficiency.

Architecture

ratatoskr <args>                                 [shell entry]
  │
  └─ ratatoskr.cli.main(argv)
       │
       ├─ args.send_content is not None ──► asyncio.run(_amain(args))
       │     │
       │     ├─ open AsyncClient
       │     ├─ create_session (if --new)
       │     ├─ state = CliPresenterState()                 ◄── NEW
       │     ├─ _run_turn(..., state=state)
       │     │     │
       │     │     └─ for event in stream_turn(...):
       │     │         state.render(event, stdout=sys.stdout,
       │     │                       stderr=sys.stderr)     ◄── NEW
       │     │         # state owns: thinking_buffer, thinking_open,
       │     │         #             text_written_since_newline
       │     └─ close AsyncClient
       │
       └─ args.send_content is None ──► run_tui(args)
             │
             └─ asyncio.run(_resolve_then_run(args))
                  │
                  └─ async with AsyncClient(...):
                       ├─ pre-flight session resolve
                       ├─ app = RatatoskrApp(args, ...)
                       │     └─ compose():
                       │         Header()
                       │         RichLog(id="transcript", ...)
                       │         Input(id="prompt", ...)
                       │         Static("", id="identity")
                       │         Static("", id="hint")
                       │         Static("", id="thinking-current",  ◄── NEW
                       │                display=False)
                       │         Footer()
                       │
                       └─ on_input_submitted: spawn _stream_turn_worker
                            └─ _stream_turn_worker(content):
                                 ├─ state = TuiPresenterState(app=self)  ◄── NEW
                                 └─ for event in stream_turn(...):
                                     state.render(event, log=log,
                                                  thinking_widget=tw,
                                                  raw=self.args.raw)  ◄── NEW
                                     # state owns: thinking_buffer,
                                     #             thinking_open

In-place amendments to issue #3 (ratatoskr.cli)

CLASS CliPresenterState (NEW)

CLASS CliPresenterState
BRIEF: Stateful presenter for the cli `--send` mode. Owns per-turn rendering state: in-flight thinking buffer + whether a thinking run is currently open + whether stdout text has been written since the last newline. One instance per `_amain` call; discarded on return.
PROPERTIES:
  thinking_buffer: list[str]              # accumulated thinking content for the active run
  thinking_open: bool                     # whether a thinking run is currently open
  text_written_since_newline: bool        # whether stdout has received text without a closing \n
METHODS:
  render(event, *, stdout: TextIO, stderr: TextIO) -> None
INV-WIRE-001: One instance per `_amain` call (INV-008).
INV-WIRE-002: state.render(...) is called for every event in stream order; events MUST NOT be skipped.

FN CliPresenterState.render (NEW)

FN CliPresenterState.render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None
BRIEF: Render one event with the editorial hierarchy per INV-002. Coalesces thinking runs per INV-001; guarantees stdout newline boundary before load-bearing terminal labels per INV-005. ASCII-only output.
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))
PRE: [PRE-002 hard] stdout and stderr are writeable text IO objects
POST: [POST-001 side_effect] for Thinking events: open thinking run if not open, append delta to thinking_buffer, write delta to stderr without trailing newline
POST: [POST-002 side_effect] for non-Thinking events when thinking_open: close the thinking run by writing "\n" to stderr; set thinking_open=False; clear thinking_buffer; THEN render the new event
POST: [POST-003 side_effect] for Text events: write event.content to stdout without forcing a trailing newline; set text_written_since_newline = NOT event.content.endswith("\n") so already-terminated content does NOT trigger an extra newline before subsequent terminal labels (INV-005 boundary fires only when text lacks a trailing newline)
POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_newline, write "\n" to stdout + flush, reset flag; then write the terminal label to stderr per INV-005
POST: [POST-005 side_effect] for demoted-telemetry events (WorkerPhase, TextBoundary, ToolStart, ToolResult): write ". <label> <fields>\n" to stderr (no prefix on load-bearing)
STEPS:
  1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-002
  2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking):
       IF NOT self.thinking_open:
         stderr.write(". thinking: ")
         self.thinking_open = True
       stderr.write(event.content)
       stderr.flush()
       self.thinking_buffer.append(event.content)
       RETURN
  3. [branch, flexibility=prescriptive] IF self.thinking_open:
       # Close the open thinking run before rendering the new event
       stderr.write("\n")
       stderr.flush()
       self.thinking_open = False
       self.thinking_buffer.clear()
  4. [branch, flexibility=prescriptive] IF isinstance(event, Text):
       stdout.write(event.content)
       stdout.flush()
       # Track whether the cursor is mid-line — if content ends with \n,
       # stdout is already at column 0 and INV-005 should NOT inject another \n.
       self.text_written_since_newline = not event.content.endswith("\n")
       RETURN
  5. [branch, flexibility=prescriptive] IF isinstance(event, (Done, Error, Cancelled)):
       IF self.text_written_since_newline:
         stdout.write("\n")
         stdout.flush()
         self.text_written_since_newline = False
       # Then write the load-bearing terminal label (NO demotion prefix)
       label = _format_terminal_label(event)  # see helpers below
       stderr.write(label + "\n")
       RETURN
  6. [branch, flexibility=prescriptive] # Demoted telemetry: WorkerPhase, TextBoundary, ToolStart, ToolResult
     label = _format_demoted_label(event)
     stderr.write(". " + label + "\n")
TESTS:
  thinking_coalesce_single_run [happy,tracer]: render Thinking("hello"), Thinking(" world") in sequence; stderr captures ". thinking: hello world" (no \n yet); then render Done → stderr gets a final \n + the [done] line
  thinking_closes_on_first_non_thinking_event [happy]: Thinking → WorkerPhase → stderr has ". thinking: ...\n" (closed) then ". worker_phase: ..."
  thinking_closes_on_error [error]: Thinking → Error → thinking line closes with \n, then [error] line rendered (no discard of partial thinking)
  multiple_thinking_runs [scenario]: Thinking → Text → Thinking → Done → two separate ". thinking: ..." runs in stderr, with stdout receiving the text + \n boundary before [done]
  text_then_done_newline_boundary [trace]: Text("answer") → Done; stdout receives "answer\n" (the \n is from INV-005), stderr receives "[done] ..."
  no_text_then_done_no_extra_newline [trace]: Done with no preceding Text → stdout untouched; stderr receives only "[done] ..."
  newline_terminated_text_then_done [trace]: Text("answer\n") → Done; stdout receives "answer\n" exactly once (NO double-newline before [done]) per INV-005 + POST-003 reset rule
  cancelled_mid_thinking [scenario]: Thinking → Cancelled → thinking closes with \n; then [cancelled] (no demotion prefix)
  worker_phase_demoted [trace]: WorkerPhase → stderr line starts with ". worker_phase:" not "[worker_phase]"
  tool_start_demoted [trace]: ToolStart → stderr line starts with ". tool_start:"
  tool_result_truncated [trace]: ToolResult(result="b"*500) → stderr line has ". tool_result:" + ≤200 chars of result repr
  text_boundary_demoted [trace]: TextBoundary → stderr line starts with ". text_boundary:"
  duration_format_seconds [trace]: Done(duration_ms=5467) → stderr label contains "duration=5.5s" (not duration_ms=5467)
  duration_format_subsecond [trace]: Done(duration_ms=347) → "duration=347ms"
  duration_format_minutes [trace]: Done(duration_ms=72000) → "duration=1.2m"
  usage_format_ascii_arrow [trace]: Done(usage=...) → stderr label contains "usage 6756 in -> 126 out (6882 total, 0 cached)" (ASCII arrow)
  state_reset_per_amain [trace]: two _amain calls in one process; second one starts with thinking_open=False (fresh state)

FN _format_duration_ms (NEW helper)

FN _format_duration_ms(ms: int) -> str
BRIEF: Auto-scale duration formatting per INV-006. Locale-blind; English-only.
PRE: [PRE-001 hard] ms is a non-negative int -- assert isinstance(ms, int) and ms >= 0
POST: [POST-001 return_value] returns a short string: "<ms>ms" / "<s.s>s" / "<m.m>m"
STEPS:
  1. IF ms < 1000: RETURN f"{ms}ms"
  2. ELIF ms < 60_000: RETURN f"{ms/1000:.1f}s"
  3. ELSE: RETURN f"{ms/60_000:.1f}m"
TESTS:
  subsecond: 347 → "347ms"
  exact_one_second: 1000 → "1.0s"
  fractional_seconds: 5467 → "5.5s"
  exact_one_minute: 60000 → "1.0m"
  fractional_minutes: 72000 → "1.2m"
  zero: 0 → "0ms"

FN _format_usage (NEW helper)

FN _format_usage(usage: dict, *, arrow: str) -> str
BRIEF: Natural-language usage formatting per INV-007. `arrow="->"` in CLI, `arrow="→"` in TUI.
PRE: [PRE-001 hard] usage has the four expected keys
POST: [POST-001 return_value] returns f"{prompt} in {arrow} {completion} out ({total} total, {cached} cached)"
STEPS:
  1. p = usage["prompt_tokens"]; c = usage["completion_tokens"]; t = usage["total_tokens"]; ci = usage["cached_input_tokens"]
  2. RETURN f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
TESTS:
  ascii_arrow: arrow="->" → "6756 in -> 126 out (6882 total, 0 cached)"
  unicode_arrow: arrow="→" → "6756 in → 126 out (6882 total, 0 cached)"
  zero_cached: cached=0 → "..., 0 cached)" (literal)

_amain STEPS amended

_amain now constructs a CliPresenterState and threads it through _run_turn. The old stateless _render_event(event, stdout, stderr) call inside _run_turn becomes state.render(event, stdout=stdout, stderr=stderr).

The [create_session] session_id=... agent_id=... lifecycle line is NOT routed through state.render() — it is not an SSE Event variant (CliPresenterState.render's PRE-001 only accepts Event union members), and the lifecycle line fires before any stream event has arrived. It remains a direct sys.stderr.write(...) call inside _amain, with the . demotion prefix applied at the call site for consistency with the rest of the demoted-telemetry hierarchy:

sys.stderr.write(f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n")

Decision (was an open question): [create_session] is demoted, NOT load-bearing. It's a lifecycle observability marker, not the model's voice; demoting it preserves visual hierarchy with [done]/[error]/[cancelled] as the only no-prefix terminal events.

TESTS amendments (issue #3 in-place)

Existing per-event TESTS in _render_event block become tests on CliPresenterState.render with single-event sequences. New tests added for state transitions (above). The _run_turn tests stay; their assertions on stderr labels update for the new . prefix on demoted events and the autoscale formatting.


In-place amendments to issue #4 (ratatoskr.tui)

CLASS TuiPresenterState (NEW)

CLASS TuiPresenterState
BRIEF: Stateful presenter for the TUI mode. Owns per-turn rendering state: in-flight thinking buffer + whether a thinking run is currently open. One instance per `_stream_turn_worker` invocation; discarded when the worker exits.
PROPERTIES:
  thinking_buffer: list[str]
  thinking_open: bool
METHODS:
  render(event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (INV-008).
INV-WIRE-002: Two-views-of-thinking decoupling (INV-004): thinking_widget updates per-delta; log receives one closed entry per run.
INV-WIRE-003: Render-exception fallback (INV-009): any exception in render() is caught at the boundary; original event renders as plain labeled log line + `[render_error]` log entry.

FN TuiPresenterState.render (NEW)

FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None
BRIEF: Render one event into the TUI with INV-002 hierarchy + INV-004 two-views-of-thinking + INV-009 exception fallback. Unicode allowed in output.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
PRE: [PRE-002 hard] log and thinking_widget are valid Textual widget references
POST: [POST-001 side_effect] for Thinking events: open thinking run if not open; append delta to thinking_buffer; update thinking_widget with last ~200 chars of buffer (… prefix when truncated); make widget visible if hidden
POST: [POST-002 side_effect] for non-Thinking events when thinking_open: close the thinking run by writing ONE dim+`· ` RichLog entry with the full accumulated thinking content; clear buffer; set thinking_open=False; clear + hide thinking_widget; THEN render the new event
POST: [POST-003 side_effect] for Text events: stream content into RichLog as raw text delta (no prefix, no demotion)
POST: [POST-004 side_effect] for Done/Error/Cancelled: write a no-prefix RichLog entry with the formatted label; ensure thinking widget is cleared+hidden if it wasn't already
POST: [POST-005 side_effect] for demoted-telemetry events except Thinking (WorkerPhase, TextBoundary, ToolStart, ToolResult): write a dim+`· ` RichLog entry
POST: [POST-006 side_effect] on Done events with NOT raw: after the [done] line, write a Rule + Markdown render of event.response per existing issue #4 INV-005
POST: [POST-007 exception] never propagates; on any internal exception, write a plain labeled RichLog line for the original event + a `[render_error] <type>` line (class name ONLY, no exception message — INV-009 security clause); degrade gracefully
STEPS:
  1. [setup, flexibility=prescriptive] Try-block wraps the whole body for INV-009 fallback
  2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking):
       IF NOT self.thinking_open:
         thinking_widget.display = True
         self.thinking_open = True
       self.thinking_buffer.append(event.content)
       acc = "".join(self.thinking_buffer)
       display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
       thinking_widget.update(display_text)
       RETURN
  3. [branch, flexibility=prescriptive] IF self.thinking_open:
       full_thinking = "".join(self.thinking_buffer)
       log.write(_dim_demoted("· thinking: " + full_thinking))  # ONE closed entry per run
       self.thinking_buffer.clear()
       self.thinking_open = False
       thinking_widget.update("")
       thinking_widget.display = False
  4. [branch, flexibility=prescriptive] IF isinstance(event, Text):
       log.write(event.content)  # streaming; no prefix
       RETURN
  5. [branch, flexibility=prescriptive] IF isinstance(event, Done):
       log.write(_format_terminal_label(event, arrow="→"))
       IF NOT raw:
         from rich.markdown import Markdown
         from rich.rule import Rule
         log.write(Rule())
         log.write(Markdown(event.response))
       # Belt-and-braces: ensure widget is cleared+hidden
       thinking_widget.update("")
       thinking_widget.display = False
       RETURN
  6. [branch, flexibility=prescriptive] IF isinstance(event, (Error, Cancelled)):
       log.write(_format_terminal_label(event, arrow="→"))
       thinking_widget.update("")
       thinking_widget.display = False
       RETURN
  7. [branch, flexibility=prescriptive] # Demoted telemetry except Thinking
     label = _format_demoted_label(event, arrow="→")
     log.write(_dim_demoted("· " + label))
  CATCH (Exception as exc):
     # INV-009 fallback: type-only, NO exception message (security)
     log.write(_format_plain_label(event))  # pre-amendment behavior
     log.write(f"[render_error] {type(exc).__name__}")
TESTS:
  thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events; thinking_widget.update called 3 times with cumulative content (last delta = full content truncated); RichLog has 0 thinking entries yet
  thinking_closes_one_richlog_entry [happy]: Thinking, Thinking, WorkerPhase → RichLog has exactly ONE thinking entry (closed run) + the worker_phase entry; thinking_widget cleared+hidden
  thinking_widget_truncation [trace]: thinking_buffer 500 chars → widget displays "…" + last 200
  thinking_widget_visibility_lifecycle [trace]: hidden at start; visible after first Thinking; hidden after closing event
  multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking, Text, Thinking, Done → TWO closed thinking RichLog entries
  cancelled_mid_thinking_closes [scenario]: Thinking, Cancelled → one closed thinking RichLog entry, then [cancelled] entry; widget hidden
  done_renders_markdown_after_label [happy]: Text("hi"), Done(response="hi") with NOT raw → [done] line, Rule, Markdown(text="hi") in RichLog
  raw_flag_skips_markdown [trace]: same with raw=True → no Rule, no Markdown
  worker_phase_demoted [trace]: WorkerPhase → RichLog line starts with "· worker_phase:" with dim style
  tool_start_demoted [trace]: ToolStart → RichLog line starts with "· tool_start:" with dim style
  text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion
  render_exception_fallback [adversarial]: monkeypatch thinking_widget.update to raise → RichLog gets a plain labeled fallback line + a `[render_error] <ExceptionClassName>` line (no message content per INV-009 security); worker does not crash
  state_reset_per_worker [trace]: two consecutive _stream_turn_worker invocations; second one starts with thinking_open=False (fresh state)
  duration_format_seconds [trace]: Done(duration_ms=5467) → label contains "duration=5.5s"
  usage_format_unicode_arrow [trace]: Done → label contains "usage 6756 in → 126 out (6882 total, 0 cached)"

RatatoskrApp.compose STEPS amended

compose() now yields one additional widget: Static("", id="thinking-current"). The widget is hidden by default — implementer either constructs with display=False (if Static's constructor supports it directly) OR sets widget.display = False in on_mount after compose. Visibility is toggled via Textual's reactive Widget.display: bool attribute (widget.display = True/False) per INV-004 lifecycle. The contract does NOT prescribe classes="hidden" + CSS or widget.styles.display mutation — both are equivalent in effect, but the reactive-attribute path is the chosen mechanism for this amendment to avoid implementation churn.

_stream_turn_worker STEPS amended

_stream_turn_worker now constructs a TuiPresenterState at the top and threads it through the per-event loop. Replaces the existing _render_event_to_log(event, log=log, raw=self.args.raw) call with state.render(event, log=log, thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw).

TESTS amendments (issue #4 in-place)

Existing per-event TESTS in _render_event_to_log block become tests on TuiPresenterState.render. New tests added for state transitions (above) and the new Static widget lifecycle. test_happy_text_done_renders_markdown (issue #4) stays but updates its assertions for the new no-prefix Text + label format.


Acceptance

  • Issue #3 contract amended in-place per the cli section above.
  • Issue #4 contract amended in-place per the tui section above.
  • All amended contracts drift-check clean.
  • All existing tests + new CliPresenterState / TuiPresenterState coverage GREEN under uv run pytest tests/.
  • uv run ruff check src/ tests/ clean.
  • Boundary smoke tests/test_no_worldtree_imports.py still passes.
  • Manual smoke: source env.sh && uv run ratatoskr --new --agent mimir --send "test" produces:
    • Thinking deltas render as ONE coalesced growing line ending with \n on the first non-thinking event.
    • WorkerPhase/TextBoundary/ToolStart/ToolResult lines start with . prefix.
    • [done] lands on its own line below the assistant text, with usage 6756 in -> 126 out (...) and duration=5.5s formatting.
  • Manual smoke TUI: source env.sh && uv run ratatoskr --new --agent mimir shows the dedicated Static(id="thinking-current") widget visible during thinking, hidden after the turn terminal, with one closed RichLog entry per thinking run.
  • Regression: --send "..." > out.txt continues to write ONLY the assistant's text content to out.txt (no demoted-telemetry leakage).

Dependencies

  • Issue #3 (ratatoskr.cli) — landed on main; this issue amends its rendering contract in-place.
  • Issue #4 (ratatoskr.tui) — landed on main; this issue amends its rendering contract in-place.
  • Cross-frontier design pass with eitri-smithy-dev (althing 01KSBE52YZR5E3SPTKA672JE43) — settled the 16 confirmed decisions + 4 material divergences baked into this contract.