diff --git a/docs/contracts/issues/12.contract.md b/docs/contracts/issues/12.contract.md new file mode 100644 index 0000000..c2eac2c --- /dev/null +++ b/docs/contracts/issues/12.contract.md @@ -0,0 +1,570 @@ +--- +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 ".