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

571 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.