---
contract_version: "2.1"
target_module: "ratatoskr.cli + ratatoskr.tui"
scope: "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."
depends_on:
  - "ratatoskr.sse_client"
used_by: []
language: "python"
complexity: "medium"
estimated_loc: 220
confidence: 0.85
assumptions:
  - "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'."
open_questions:
  - "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."
prd:
  issue: 12
  issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/12"
  body_sha256_16: "9e5daa500cc3df85"
  lock_in_comment_id: null
  lock_in_sha256_16: null
  lock_in_at: null
  pinned_at: "2026-05-23T22:13:28+00:00"
dependencies:
  - issue: 3
    path: "src/ratatoskr/cli.py"
    reason: "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: 4
    path: "src/ratatoskr/tui.py"
    reason: "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)

```contract
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)

```contract
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)

```contract
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)

```contract
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:

```python
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)

```contract
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)

```contract
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.
