--- 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] ''` 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] ` 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 ``; the operator can attach a debugger or read logs if the bare `` 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 [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 ".