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).
38 KiB
contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
| contract_version | target_module | scope | depends_on | used_by | language | complexity | estimated_loc | confidence | assumptions | open_questions | prd | dependencies | |||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2.1 | ratatoskr.cli + ratatoskr.tui | Presenter contract semantics amendment — replace stateless 'one labeled line per non-Text event' rendering with stateful event coalescing + visual hierarchy. Both presenters gain a small renderer-state object that owns `thinking_buffer` / `thinking_open` / `text_written_since_newline`. Thinking deltas coalesce into a single growing run, closed on the first non-thinking event. Demoted-telemetry events (`WorkerPhase`, `Thinking`, `TextBoundary`, `ToolStart`, `ToolResult`) get visual demotion: `. ` prefix on stderr in CLI (ASCII), dim style + `· ` prefix in TUI's RichLog. Load-bearing events (`Text`, `Done`, `Error`, `Cancelled`) keep no demotion prefix. TUI adds a dedicated `Static(id='thinking-current')` widget for live per-delta updates alongside the RichLog's chronological one-closed-entry-per-run. `duration_ms` auto-scales (`347ms` / `5.5s` / `1.2m`); `usage` renders as natural-language flow with ASCII arrow in CLI, Unicode arrow in TUI. CLI's `[done]` / `[error]` / `[cancelled]` labels get a stdout-flush + newline-boundary guarantee. No new public flags; no wire-level surface change. Two parallel in-place amendments to issues #3 (cli) and #4 (tui), landing in one commit at v0.2.0. Mission refinement (per persistent-memory amendment): 'the observability surface presented at the right zoom level for the operator's task' — raw per-event streaming is the wrong zoom for debugging; coalesced thinking + demoted telemetry is the right zoom. |
|
python | medium | 220 | 0.85 |
|
|
|
|
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:
- 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. - text_boundary fires inline mid-text, visually breaking sentences.
- worker_phase renders with the same visual weight as actual model output, drowning load-bearing signal.
- ToolStart / ToolResult share the telemetry-vs-transcript tension.
- duration_ms / usage are unformatted (
duration_ms=5467,usage={'prompt_tokens': 6756, ...}). [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 thread01KSBE52YZR5E3SPTKA672JE43); 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
\nto stdout BEFORE the label is written to stderr when text has been written this turn. duration_msformats:347ms/5.5s/1.2mautoscale.usageformats: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 inRatatoskrApp.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
Thinkingevents 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\nwritten at closure. The TUI emits per-delta updates to thethinking-currentStatic 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 nextThinkingevent. -
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.
- CLI:
-
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
Thinkingevent; per-run RichLog write fires once on closure. No per-delta RichLog writes for Thinking events.
- The dedicated
-
INV-005 [hard]: CLI stdout/stderr newline boundary — before writing any load-bearing terminal label (
[done],[error],[cancelled]) to stderr, the renderer state MUST checktext_written_since_newline; if true, write\nto 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\ncleanly 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
CliPresenterStateinstance constructed per_amaincall; discarded on return. - One
TuiPresenterStateinstance constructed per_stream_turn_workerinvocation (one per turn); discarded when the worker exits (success, error, or cancellation). - No long-lived singleton; no cross-turn state in either presenter.
- One
-
INV-009 [hard]: TUI render-exception fallback — if any exception fires inside
TuiPresenterState.render()(e.g., thethinking-currentwidget 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-thinkingare 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 onhttpx,httpx-sse,textual,richcover the surface.
Out of scope
--quiet/--debug/--verboseflags. 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.5sis locale-blind by design. - Cross-process resume / transcript persistence. Per design-brief §8d, deferred to v2.
- Smart truncation of
ToolResult.resultbeyond 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-renderflag.git checkout v0.1.0is the escape hatch.
Constraints
- [compatibility] Spec pin unchanged. The wire surface
(
Eventunion fromratatoskr.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
PresenterStateclasses withdataclass(slots=True)for memory efficiency.
Architecture
ratatoskr <args> [shell entry]
│
└─ ratatoskr.cli.main(argv)
│
├─ args.send_content is not None ──► asyncio.run(_amain(args))
│ │
│ ├─ open AsyncClient
│ ├─ create_session (if --new)
│ ├─ state = CliPresenterState() ◄── NEW
│ ├─ _run_turn(..., state=state)
│ │ │
│ │ └─ for event in stream_turn(...):
│ │ state.render(event, stdout=sys.stdout,
│ │ stderr=sys.stderr) ◄── NEW
│ │ # state owns: thinking_buffer, thinking_open,
│ │ # text_written_since_newline
│ └─ close AsyncClient
│
└─ args.send_content is None ──► run_tui(args)
│
└─ asyncio.run(_resolve_then_run(args))
│
└─ async with AsyncClient(...):
├─ pre-flight session resolve
├─ app = RatatoskrApp(args, ...)
│ └─ compose():
│ Header()
│ RichLog(id="transcript", ...)
│ Input(id="prompt", ...)
│ Static("", id="identity")
│ Static("", id="hint")
│ Static("", id="thinking-current", ◄── NEW
│ display=False)
│ Footer()
│
└─ on_input_submitted: spawn _stream_turn_worker
└─ _stream_turn_worker(content):
├─ state = TuiPresenterState(app=self) ◄── NEW
└─ for event in stream_turn(...):
state.render(event, log=log,
thinking_widget=tw,
raw=self.args.raw) ◄── NEW
# state owns: thinking_buffer,
# thinking_open
In-place amendments to issue #3 (ratatoskr.cli)
CLASS CliPresenterState (NEW)
CLASS CliPresenterState
BRIEF: Stateful presenter for the cli `--send` mode. Owns per-turn rendering state: in-flight thinking buffer + whether a thinking run is currently open + whether stdout text has been written since the last newline. One instance per `_amain` call; discarded on return.
PROPERTIES:
thinking_buffer: list[str] # accumulated thinking content for the active run
thinking_open: bool # whether a thinking run is currently open
text_written_since_newline: bool # whether stdout has received text without a closing \n
METHODS:
render(event, *, stdout: TextIO, stderr: TextIO) -> None
INV-WIRE-001: One instance per `_amain` call (INV-008).
INV-WIRE-002: state.render(...) is called for every event in stream order; events MUST NOT be skipped.
FN CliPresenterState.render (NEW)
FN CliPresenterState.render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None
BRIEF: Render one event with the editorial hierarchy per INV-002. Coalesces thinking runs per INV-001; guarantees stdout newline boundary before load-bearing terminal labels per INV-005. ASCII-only output.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled))
PRE: [PRE-002 hard] stdout and stderr are writeable text IO objects
POST: [POST-001 side_effect] for Thinking events: open thinking run if not open, append delta to thinking_buffer, write delta to stderr without trailing newline
POST: [POST-002 side_effect] for non-Thinking events when thinking_open: close the thinking run by writing "\n" to stderr; set thinking_open=False; clear thinking_buffer; THEN render the new event
POST: [POST-003 side_effect] for Text events: write event.content to stdout without forcing a trailing newline; set text_written_since_newline = NOT event.content.endswith("\n") so already-terminated content does NOT trigger an extra newline before subsequent terminal labels (INV-005 boundary fires only when text lacks a trailing newline)
POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_newline, write "\n" to stdout + flush, reset flag; then write the terminal label to stderr per INV-005
POST: [POST-005 side_effect] for demoted-telemetry events (WorkerPhase, TextBoundary, ToolStart, ToolResult): write ". <label> <fields>\n" to stderr (no prefix on load-bearing)
STEPS:
1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-002
2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking):
IF NOT self.thinking_open:
stderr.write(". thinking: ")
self.thinking_open = True
stderr.write(event.content)
stderr.flush()
self.thinking_buffer.append(event.content)
RETURN
3. [branch, flexibility=prescriptive] IF self.thinking_open:
# Close the open thinking run before rendering the new event
stderr.write("\n")
stderr.flush()
self.thinking_open = False
self.thinking_buffer.clear()
4. [branch, flexibility=prescriptive] IF isinstance(event, Text):
stdout.write(event.content)
stdout.flush()
# Track whether the cursor is mid-line — if content ends with \n,
# stdout is already at column 0 and INV-005 should NOT inject another \n.
self.text_written_since_newline = not event.content.endswith("\n")
RETURN
5. [branch, flexibility=prescriptive] IF isinstance(event, (Done, Error, Cancelled)):
IF self.text_written_since_newline:
stdout.write("\n")
stdout.flush()
self.text_written_since_newline = False
# Then write the load-bearing terminal label (NO demotion prefix)
label = _format_terminal_label(event) # see helpers below
stderr.write(label + "\n")
RETURN
6. [branch, flexibility=prescriptive] # Demoted telemetry: WorkerPhase, TextBoundary, ToolStart, ToolResult
label = _format_demoted_label(event)
stderr.write(". " + label + "\n")
TESTS:
thinking_coalesce_single_run [happy,tracer]: render Thinking("hello"), Thinking(" world") in sequence; stderr captures ". thinking: hello world" (no \n yet); then render Done → stderr gets a final \n + the [done] line
thinking_closes_on_first_non_thinking_event [happy]: Thinking → WorkerPhase → stderr has ". thinking: ...\n" (closed) then ". worker_phase: ..."
thinking_closes_on_error [error]: Thinking → Error → thinking line closes with \n, then [error] line rendered (no discard of partial thinking)
multiple_thinking_runs [scenario]: Thinking → Text → Thinking → Done → two separate ". thinking: ..." runs in stderr, with stdout receiving the text + \n boundary before [done]
text_then_done_newline_boundary [trace]: Text("answer") → Done; stdout receives "answer\n" (the \n is from INV-005), stderr receives "[done] ..."
no_text_then_done_no_extra_newline [trace]: Done with no preceding Text → stdout untouched; stderr receives only "[done] ..."
newline_terminated_text_then_done [trace]: Text("answer\n") → Done; stdout receives "answer\n" exactly once (NO double-newline before [done]) per INV-005 + POST-003 reset rule
cancelled_mid_thinking [scenario]: Thinking → Cancelled → thinking closes with \n; then [cancelled] (no demotion prefix)
worker_phase_demoted [trace]: WorkerPhase → stderr line starts with ". worker_phase:" not "[worker_phase]"
tool_start_demoted [trace]: ToolStart → stderr line starts with ". tool_start:"
tool_result_truncated [trace]: ToolResult(result="b"*500) → stderr line has ". tool_result:" + ≤200 chars of result repr
text_boundary_demoted [trace]: TextBoundary → stderr line starts with ". text_boundary:"
duration_format_seconds [trace]: Done(duration_ms=5467) → stderr label contains "duration=5.5s" (not duration_ms=5467)
duration_format_subsecond [trace]: Done(duration_ms=347) → "duration=347ms"
duration_format_minutes [trace]: Done(duration_ms=72000) → "duration=1.2m"
usage_format_ascii_arrow [trace]: Done(usage=...) → stderr label contains "usage 6756 in -> 126 out (6882 total, 0 cached)" (ASCII arrow)
state_reset_per_amain [trace]: two _amain calls in one process; second one starts with thinking_open=False (fresh state)
FN _format_duration_ms (NEW helper)
FN _format_duration_ms(ms: int) -> str
BRIEF: Auto-scale duration formatting per INV-006. Locale-blind; English-only.
PRE: [PRE-001 hard] ms is a non-negative int -- assert isinstance(ms, int) and ms >= 0
POST: [POST-001 return_value] returns a short string: "<ms>ms" / "<s.s>s" / "<m.m>m"
STEPS:
1. IF ms < 1000: RETURN f"{ms}ms"
2. ELIF ms < 60_000: RETURN f"{ms/1000:.1f}s"
3. ELSE: RETURN f"{ms/60_000:.1f}m"
TESTS:
subsecond: 347 → "347ms"
exact_one_second: 1000 → "1.0s"
fractional_seconds: 5467 → "5.5s"
exact_one_minute: 60000 → "1.0m"
fractional_minutes: 72000 → "1.2m"
zero: 0 → "0ms"
FN _format_usage (NEW helper)
FN _format_usage(usage: dict, *, arrow: str) -> str
BRIEF: Natural-language usage formatting per INV-007. `arrow="->"` in CLI, `arrow="→"` in TUI.
PRE: [PRE-001 hard] usage has the four expected keys
POST: [POST-001 return_value] returns f"{prompt} in {arrow} {completion} out ({total} total, {cached} cached)"
STEPS:
1. p = usage["prompt_tokens"]; c = usage["completion_tokens"]; t = usage["total_tokens"]; ci = usage["cached_input_tokens"]
2. RETURN f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
TESTS:
ascii_arrow: arrow="->" → "6756 in -> 126 out (6882 total, 0 cached)"
unicode_arrow: arrow="→" → "6756 in → 126 out (6882 total, 0 cached)"
zero_cached: cached=0 → "..., 0 cached)" (literal)
_amain STEPS amended
_amain now constructs a CliPresenterState and threads it through _run_turn. The old stateless _render_event(event, stdout, stderr) call inside _run_turn becomes state.render(event, stdout=stdout, stderr=stderr).
The [create_session] session_id=... agent_id=... lifecycle line is NOT routed through state.render() — it is not an SSE Event variant (CliPresenterState.render's PRE-001 only accepts Event union members), and the lifecycle line fires before any stream event has arrived. It remains a direct sys.stderr.write(...) call inside _amain, with the . demotion prefix applied at the call site for consistency with the rest of the demoted-telemetry hierarchy:
sys.stderr.write(f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n")
Decision (was an open question): [create_session] is demoted, NOT load-bearing. It's a lifecycle observability marker, not the model's voice; demoting it preserves visual hierarchy with [done]/[error]/[cancelled] as the only no-prefix terminal events.
TESTS amendments (issue #3 in-place)
Existing per-event TESTS in _render_event block become tests on CliPresenterState.render with single-event sequences. New tests added for state transitions (above). The _run_turn tests stay; their assertions on stderr labels update for the new . prefix on demoted events and the autoscale formatting.
In-place amendments to issue #4 (ratatoskr.tui)
CLASS TuiPresenterState (NEW)
CLASS TuiPresenterState
BRIEF: Stateful presenter for the TUI mode. Owns per-turn rendering state: in-flight thinking buffer + whether a thinking run is currently open. One instance per `_stream_turn_worker` invocation; discarded when the worker exits.
PROPERTIES:
thinking_buffer: list[str]
thinking_open: bool
METHODS:
render(event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (INV-008).
INV-WIRE-002: Two-views-of-thinking decoupling (INV-004): thinking_widget updates per-delta; log receives one closed entry per run.
INV-WIRE-003: Render-exception fallback (INV-009): any exception in render() is caught at the boundary; original event renders as plain labeled log line + `[render_error]` log entry.
FN TuiPresenterState.render (NEW)
FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None
BRIEF: Render one event into the TUI with INV-002 hierarchy + INV-004 two-views-of-thinking + INV-009 exception fallback. Unicode allowed in output.
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
PRE: [PRE-002 hard] log and thinking_widget are valid Textual widget references
POST: [POST-001 side_effect] for Thinking events: open thinking run if not open; append delta to thinking_buffer; update thinking_widget with last ~200 chars of buffer (… prefix when truncated); make widget visible if hidden
POST: [POST-002 side_effect] for non-Thinking events when thinking_open: close the thinking run by writing ONE dim+`· ` RichLog entry with the full accumulated thinking content; clear buffer; set thinking_open=False; clear + hide thinking_widget; THEN render the new event
POST: [POST-003 side_effect] for Text events: stream content into RichLog as raw text delta (no prefix, no demotion)
POST: [POST-004 side_effect] for Done/Error/Cancelled: write a no-prefix RichLog entry with the formatted label; ensure thinking widget is cleared+hidden if it wasn't already
POST: [POST-005 side_effect] for demoted-telemetry events except Thinking (WorkerPhase, TextBoundary, ToolStart, ToolResult): write a dim+`· ` RichLog entry
POST: [POST-006 side_effect] on Done events with NOT raw: after the [done] line, write a Rule + Markdown render of event.response per existing issue #4 INV-005
POST: [POST-007 exception] never propagates; on any internal exception, write a plain labeled RichLog line for the original event + a `[render_error] <type>` line (class name ONLY, no exception message — INV-009 security clause); degrade gracefully
STEPS:
1. [setup, flexibility=prescriptive] Try-block wraps the whole body for INV-009 fallback
2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking):
IF NOT self.thinking_open:
thinking_widget.display = True
self.thinking_open = True
self.thinking_buffer.append(event.content)
acc = "".join(self.thinking_buffer)
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
thinking_widget.update(display_text)
RETURN
3. [branch, flexibility=prescriptive] IF self.thinking_open:
full_thinking = "".join(self.thinking_buffer)
log.write(_dim_demoted("· thinking: " + full_thinking)) # ONE closed entry per run
self.thinking_buffer.clear()
self.thinking_open = False
thinking_widget.update("")
thinking_widget.display = False
4. [branch, flexibility=prescriptive] IF isinstance(event, Text):
log.write(event.content) # streaming; no prefix
RETURN
5. [branch, flexibility=prescriptive] IF isinstance(event, Done):
log.write(_format_terminal_label(event, arrow="→"))
IF NOT raw:
from rich.markdown import Markdown
from rich.rule import Rule
log.write(Rule())
log.write(Markdown(event.response))
# Belt-and-braces: ensure widget is cleared+hidden
thinking_widget.update("")
thinking_widget.display = False
RETURN
6. [branch, flexibility=prescriptive] IF isinstance(event, (Error, Cancelled)):
log.write(_format_terminal_label(event, arrow="→"))
thinking_widget.update("")
thinking_widget.display = False
RETURN
7. [branch, flexibility=prescriptive] # Demoted telemetry except Thinking
label = _format_demoted_label(event, arrow="→")
log.write(_dim_demoted("· " + label))
CATCH (Exception as exc):
# INV-009 fallback: type-only, NO exception message (security)
log.write(_format_plain_label(event)) # pre-amendment behavior
log.write(f"[render_error] {type(exc).__name__}")
TESTS:
thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events; thinking_widget.update called 3 times with cumulative content (last delta = full content truncated); RichLog has 0 thinking entries yet
thinking_closes_one_richlog_entry [happy]: Thinking, Thinking, WorkerPhase → RichLog has exactly ONE thinking entry (closed run) + the worker_phase entry; thinking_widget cleared+hidden
thinking_widget_truncation [trace]: thinking_buffer 500 chars → widget displays "…" + last 200
thinking_widget_visibility_lifecycle [trace]: hidden at start; visible after first Thinking; hidden after closing event
multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking, Text, Thinking, Done → TWO closed thinking RichLog entries
cancelled_mid_thinking_closes [scenario]: Thinking, Cancelled → one closed thinking RichLog entry, then [cancelled] entry; widget hidden
done_renders_markdown_after_label [happy]: Text("hi"), Done(response="hi") with NOT raw → [done] line, Rule, Markdown(text="hi") in RichLog
raw_flag_skips_markdown [trace]: same with raw=True → no Rule, no Markdown
worker_phase_demoted [trace]: WorkerPhase → RichLog line starts with "· worker_phase:" with dim style
tool_start_demoted [trace]: ToolStart → RichLog line starts with "· tool_start:" with dim style
text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion
render_exception_fallback [adversarial]: monkeypatch thinking_widget.update to raise → RichLog gets a plain labeled fallback line + a `[render_error] <ExceptionClassName>` line (no message content per INV-009 security); worker does not crash
state_reset_per_worker [trace]: two consecutive _stream_turn_worker invocations; second one starts with thinking_open=False (fresh state)
duration_format_seconds [trace]: Done(duration_ms=5467) → label contains "duration=5.5s"
usage_format_unicode_arrow [trace]: Done → label contains "usage 6756 in → 126 out (6882 total, 0 cached)"
RatatoskrApp.compose STEPS amended
compose() now yields one additional widget: Static("", id="thinking-current"). The widget is hidden by default — implementer either constructs with display=False (if Static's constructor supports it directly) OR sets widget.display = False in on_mount after compose. Visibility is toggled via Textual's reactive Widget.display: bool attribute (widget.display = True/False) per INV-004 lifecycle. The contract does NOT prescribe classes="hidden" + CSS or widget.styles.display mutation — both are equivalent in effect, but the reactive-attribute path is the chosen mechanism for this amendment to avoid implementation churn.
_stream_turn_worker STEPS amended
_stream_turn_worker now constructs a TuiPresenterState at the top and threads it through the per-event loop. Replaces the existing _render_event_to_log(event, log=log, raw=self.args.raw) call with state.render(event, log=log, thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw).
TESTS amendments (issue #4 in-place)
Existing per-event TESTS in _render_event_to_log block become tests on TuiPresenterState.render. New tests added for state transitions (above) and the new Static widget lifecycle. test_happy_text_done_renders_markdown (issue #4) stays but updates its assertions for the new no-prefix Text + label format.
Acceptance
- Issue #3 contract amended in-place per the cli section above.
- Issue #4 contract amended in-place per the tui section above.
- All amended contracts drift-check clean.
- All existing tests + new
CliPresenterState/TuiPresenterStatecoverage GREEN underuv run pytest tests/. uv run ruff check src/ tests/clean.- Boundary smoke
tests/test_no_worldtree_imports.pystill 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
\non the first non-thinking event. - WorkerPhase/TextBoundary/ToolStart/ToolResult lines start with
.prefix. [done]lands on its own line below the assistant text, withusage 6756 in -> 126 out (...)andduration=5.5sformatting.
- Thinking deltas render as ONE coalesced growing line ending with
- Manual smoke TUI:
source env.sh && uv run ratatoskr --new --agent mimirshows the dedicatedStatic(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.