Files
ratatoskr/docs/contracts/issues/13.contract.md
T
vh 9918c10acf fix(tui): coalesce thinking deltas on \n (v0.7.1)
Operator: "thinking tokens seem to be split by token — each on a
newline, is that correct? We don't want that."

Root cause: v0.6.5 wrote each Thinking SSE delta as its own
`thinking_log.write(event.content)` call. Worldtree emits Thinking
events at token granularity (per-token or per-few-tokens), so EACH
token became its own RichLog line — visually choppy, one short
fragment per visual row. Wrong UX.

## Fix: coalesce-on-newline

Thinking deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`
(new str field). On each Thinking event:

  1. Append delta content to buffer.
  2. Flush every COMPLETE line (chars before each `\n`) as one
     thinking_log.write(line) call.
  3. Leave the post-final-`\n` tail in the buffer for the next delta.

On any non-thinking event (run close):
  1. Flush remaining buffer tail (if any) as one final line.
  2. Write Rule(end).

Empty lines (blank paragraph separators in the model's `\n\n` flow)
are skipped — they'd render as no-content RichLog entries which
just add vertical noise. Natural paragraph breaks become single
visible lines; multi-paragraph thinking renders top-to-bottom.

## Verified live (tier-3 smoke against personal Worldtree)

Defined a `thinky-smoke` agent via `python -m ratatoskr.tier3 define`,
asked "What is 12 times 13?". Thinking pane rendered with natural
paragraph chunks:

  ── turn N · thinking #1 start ──
  Thinking Process:
  1.  **Analyze the Request:** The user wants to know the result of $12 \times 13$.
  2.  **Calculate:**
      *   Method 1: Standard multiplication.
          $$12 \times 10 = 120$$
          $$12 \times 3 = 36$$
          $$120 + 36 = 156$$
      *   Method 2: $(10 + 2)(10 + 3) = 100 + 30 + 20 + 6 = 156$.
  ── turn N · thinking #1 end ──

Each line = one natural paragraph or list item. No per-token fragments.

## Edge cases noted

- Long-running thinking with NO `\n` at all stays buffered until run
  close → operator sees nothing until close. Possible follow-up: add
  a length-threshold flush (e.g., > 500 chars → flush at the last
  space). For now this is acceptable; thinking content typically has
  `\n` breaks every few sentences.
- Empty deltas (`""`) are ignored implicitly — no buffer growth, no
  flush.
- `\n` at the very start of a delta flushes whatever was buffered
  before, then leaves the empty post-`\n` tail (empty string) in the
  buffer, which doesn't show up as an empty line because of the
  `if line:` guard.

## Contract amendment

docs/contracts/issues/13.contract.md INV-022 amended for v0.7.1
coalesce semantics. Drift-check clean.

## Tests

265/265 GREEN; ruff clean. Two updated tests:

- `test_thinking_streams_into_thinking_log` → renamed
  `test_thinking_coalesces_until_newline`: 3 token-shaped deltas
  with no `\n` → only Rule(start) writes, buffer holds accumulated.
- NEW `test_thinking_flushes_on_newline`: delta carrying `\n` →
  Rule(start) + accumulated line + clear buffer.
- `test_thinking_closes_to_thinking_log`: 2 deltas "a", "b" +
  close → Rule(start) + tail-flush "ab" + Rule(end) = 3 writes
  (was 4 with per-delta).

Patch bump (v0.7.0 → v0.7.1) — internal presenter routing change;
no public-API or layout change.
2026-05-24 20:39:55 -07:00

21 KiB
Raw Blame History

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.tui Design-brief §5 v1 entry point: reshape the TUI from vertical-stack single-pane to Horizontal two-column with `TabbedContent` on the right; first (and only v1) tab is `Tools`, which consumes `ToolStart` / `ToolResult` SSE events that previously rendered inline in the transcript. Pure in-place amendment to issue #4 + #12 — no new modules, no new files apart from this contract. The CLI (`ratatoskr.cli`) is unaffected: `--send` mode is non-interactive and keeps its current inline tool-event rendering. Substrate move only: persona / admin-events / bifrost-state / server-log panes stay deferred (blocked on remote-Worldtree topology + admin scope + opt-in flag). The TabbedContent shape makes them additive — when a blocker resolves the new pane plugs in as another TabPane sibling without further layout work.
textual
python medium 180 0.85
Textual's `TabbedContent(*titles, initial='')` + `TabPane(title, *children, id=...)` is the right primitive for the right-column tabs (verified API at textual.widgets._tabbed_content). One `TabPane("Tools", tools_log, id="tools-tab")` in v1; additional siblings get appended as Persona/AdminEvents/BifrostState/ServerLog land.
Textual's `Horizontal` + `Vertical` containers compose the two-column split (verified at textual.containers). Width via CSS `width: 2fr` on the left container + `width: 1fr` on the right container gives the 2:1 chat-primary split.
**The presenter contract amendment is small and tightly scoped**: `TuiPresenterState.render` gains a `tools_log: RichLog` parameter alongside the existing `log: RichLog` (main transcript) + `thinking_widget: Static`. `ToolStart` / `ToolResult` events route to `tools_log`; every other event (Text, Thinking, WorkerPhase, Done, Error, Cancelled, TextBoundary) keeps its existing routing to `log` + `thinking_widget`. The plain-label fallback path in `_plain_label` (issue #12 INV-009 render-exception recovery) keeps its current shape — only the routing target changes.
**Tool events are routed, not duplicated**. The brief's §5 wording 'side pane (inline-from-SSE for v1)' factors tool events OUT of the main transcript. A consumer who wants to debug a tool-using turn now reads the Tools pane; the main transcript stays focused on assistant text. Trade-off: a fast-skim of the transcript no longer shows tool activity inline; if that hurts the debug ergonomics empirically, a follow-up issue can add a one-line `· tool_used name=...` breadcrumb to the transcript as a compromise. v1 commits to the cleaner split.
**Demoted-prefix style stays consistent across panes**. ToolStart in the Tools pane renders as `· tool_start: name=foo args={...}` — the same `· ` ASCII prefix issue #12 INV-005 established for demoted telemetry in the main transcript. Pane separation handles the visual hierarchy; prefix style stays cross-pane consistent so the operator's mental model is portable.
**Input field retains focus across tab switches** (design-brief §5 invariant: 'Tab key (Ctrl+1..5) jumps between tabs without losing focus on the input field'). INV-016 is the load-bearing invariant; the assumption about Textual's default behavior is just an implementation hint. If Textual's default `TabbedContent.active = ...` programmatic assignment preserves Input focus (current observed behavior), no extra code is needed. If a future Textual version regresses on this, the implementation MUST add `self.query_one('#prompt', Input).focus()` immediately after the `.active = ` assignment in `action_focus_tools` to satisfy INV-016. The test `test_ctrl_1_preserves_input_focus` is the regression guard; if it fails, the fix is the explicit `.focus()` call, not relaxing the invariant. Pre-existing INV-007 (Input always-focused except during error sub-states) is preserved verbatim.
**Status footer gains a `current-pane-name` element** — design-brief §5 calls for it explicitly. v1 only has one tab so the indicator stable-renders "Tools". Wiring it as a separate Static (`id='pane-name'`) docked alongside identity + hint makes it trivially extend when more tabs land — the widget is in place; the value will become dynamic in the future multi-tab issue. **No tab-switch handler wiring lands in #13.** The `on_mount` flow populates `pane-name` once with the literal string "Tools" and never updates it. Adding event-handler plumbing in v1 (a `@on(TabbedContent.TabActivated)` handler, etc.) is out of scope — that's a deliberate deferral, not an implementer's call.
**The existing `· thinking-current` widget keeps its position** — docked to the top of the left column (was docked top of the whole App; now docked top of the left Vertical container). Pre-amendment dock-fix from v0.2.1 stays; the scope of the dock just narrows from "App" to "left column" so it doesn't bleed into the right column's TabbedContent area.
**Width split is fixed `2fr : 1fr` for v1**. User-resizable splits are textual-native (via `Splitter` or similar), but adding interactive resize is its own UX surface. v1 ships fixed; if the right pane proves cramped on narrow terminals operators will tell us. Out of scope.
**TabbedContent's CSS classes**: the right column's TabbedContent + its tabbed-content wrappers (`#tabbed-content`, `.--tabs`, etc.) come with Textual's default styling. No custom CSS for tab strip in v1; if the visual feels wrong adjust later. The contract specifies the structure; the chrome stays Textual-default.
**Test strategy**: existing TestStreamTurnWorker tests for Text/Thinking/Done/Error/Cancelled routing stay GREEN unchanged (they assert what shows in the transcript log; that still shows the same content). ToolStart/ToolResult tests get adjusted to assert routing to `tools_log` instead of `log`. New tests cover layout shape (Horizontal parent exists, TabbedContent with tools-tab on the right) + Ctrl+1 binding + tools_log writes for tool events.
**Issue #12's INV-009 render-exception fallback path** stays correct: `_plain_label(event)` is still callable; the render method's except branch writes the labeled-string to the appropriate Widget (tools_log for tool events; log for everything else). The fallback writes to the same routed widget as a successful render — failure mode preserves the routing invariant.
Should the Tools tab show a count badge when new tool events arrive while the user is on a future Persona/AdminEvents tab? Draft: no for v1 — only one tab, so the question is moot. When persona/admin-events panes land, revisit: a small `[N]` badge on the tab header (`'Tools [3]'`) would help operators not miss tool activity that happens off-screen. Defer to a follow-up that touches multiple panes.
Should the Tools pane support filter-by-tool-name (e.g., show only `kb_search` results)? Draft: no for v1 — flat scroll matches the design brief's posture. The volume of tool events per turn is small enough that scrollback handles the use case. Revisit if mimir-style heavy-tool agents produce visible-cluttering volume.
Should the v1 Ctrl+1 binding be Ctrl+1 specifically, or `t` for 'tools' (no modifier)? Draft: Ctrl+1 — design brief specifies `Ctrl+1..5` as the family; matches the Ctrl-prefix discipline already used by Ctrl-C / Ctrl-D bindings. Plain-letter bindings would steal letter input from the Input field; Ctrl-prefixed is the standard escape.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
13 #13 52c8f886a9cc986a null null null 2026-05-24T02:28:28+00:00
issue path reason
4 src/ratatoskr/tui.py In-place contract amendment: `RatatoskrApp.compose()` reshapes from vertical-stack to Horizontal 2-col + TabbedContent right; `RatatoskrApp.DEFAULT_CSS` reshapes to scope dock rules to the new left/right containers; `RatatoskrApp.__init__` is unchanged (state attributes carry over); `RatatoskrApp.on_mount` gains pane-name widget population + tools_log lookup; `RatatoskrApp.BINDINGS` gains `Ctrl+1` → `action_focus_tools`; existing `action_interrupt` / `action_quit` unchanged.
issue path reason
12 src/ratatoskr/tui.py In-place contract amendment: `TuiPresenterState.render` signature widens to accept `tools_log: RichLog` alongside `log` + `thinking_widget`; ToolStart/ToolResult branches write to `tools_log` instead of `log`. `_stream_turn_worker` does the new lookup (`self.query_one('#tools-log', RichLog)`) and threads it through. All other event branches stay verbatim.

TUI layout reshape + Tools pane — §5 v1 entry point

Context

ratatoskr.tui ships v0.x as a single-pane Textual app: Header / thinking-current widget / transcript RichLog / Input / identity + hint Statics / Footer, vertical-stacked via dock: top / dock: bottom CSS (v0.2.1 layout fix). Design-brief §5 commits the product to a multi-pane debug-observability dashboard. Most of §5's panes (Persona, AdminEvents, BifrostState widget, ServerLog) are blocked on substrate that isn't here — remote-Worldtree topology blocks file-tail-based panes; issue #11's admin.events.read scope blocks the admin surfaces.

The unblocked v1 entry point is layout reshape + Tools pane together: reshape the TUI into the Horizontal two-column shape the design brief specifies, with TabbedContent on the right populated by a single Tools tab that consumes the existing ToolStart / ToolResult SSE events. No new endpoints; no scope grants; no cross-repo coordination. The shape is the foundation; subsequent panes plug in additively.

Data flow

Input (unchanged from issue #4):

  • args: ParsedArgs, session_id: str, agent_id: str | None, client: httpx.AsyncClient.
  • SSE event stream from ratatoskr.sse_client.stream_turn.

Output (unchanged):

  • Exit code via App.exit(code).

Internal routing change:

  • ToolStart / ToolResult events route to tools_log: RichLog (Tools pane) instead of the main transcript log: RichLog.
  • All other event types (Text, Thinking, WorkerPhase, TextBoundary, Done, Error, Cancelled) keep their existing routing.

Layout shape (post-amendment)

RatatoskrApp(App[int]):
  compose():
    yield Header()
    yield Horizontal(
      Vertical(
        Static(id="thinking-current"),     # dock: top of left column
        RichLog(id="transcript"),          # height: 1fr (fills middle)
        Input(id="prompt"),                # dock: bottom of left column
        id="left-column",
      ),
      Vertical(
        TabbedContent(
          TabPane("Tools", RichLog(id="tools-log"), id="tools-tab"),
          # future: TabPane("Persona", …, id="persona-tab"), etc.
          id="side-panes",
        ),
        id="right-column",
      ),
      id="main-row",
    )
    yield Static(id="identity")            # dock: bottom of App
    yield Static(id="pane-name")           # dock: bottom of App (new in §5)
    yield Static(id="hint")                # dock: bottom of App
    yield Footer()

DEFAULT_CSS reshape:

#main-row { height: 1fr; }
#left-column { width: 2fr; }
#right-column { width: 1fr; }
#thinking-current { dock: top; height: auto; }
#transcript { height: 1fr; }
#prompt { dock: bottom; }
#identity { dock: bottom; height: 1; }
#pane-name { dock: bottom; height: 1; }
#hint { dock: bottom; height: 1; }

Dock rules scope to the right container (left column for thinking-current/prompt; App for identity/pane-name/hint). The left-column prompt Input docks to the bottom of its column, not the App, so the right column's TabbedContent extends full height beside it.

Presenter routing (amendment to issue #12)

FN TuiPresenterState.render(
    event: Event,
    *,
    log: RichLog,
    thinking_widget: Static,
    tools_log: RichLog,         # NEW (issue #13)
    raw: bool,
) -> None

Steps (only the ToolStart/ToolResult cases change; every other case keeps issue #12's behavior verbatim):

  • ToolStart → write · tool_start: name=<name> args=<args!r> to tools_log (not log).
  • ToolResult → write · tool_result: name=<name> duration_ms=<n> result=<r!r:.200> to tools_log (not log).
  • All other events → unchanged routing per issue #12 INV-005.
  • Render-exception fallback (_plain_label(event)): write to tools_log if the event is ToolStart/ToolResult; write to log otherwise. Routing preservation under failure.

Keybindings (amendment)

BINDINGS: ClassVar[list[Binding]] = [
  Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
  Binding("ctrl+d", "quit", "Exit immediately", priority=True),
  Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),  # NEW
]

def action_focus_tools(self) -> None:
    self.query_one(TabbedContent).active = "tools-tab"
    # Input focus is preserved by Textual's default behavior — TabbedContent
    # doesn't steal focus when `.active` is set programmatically.

Ctrl+1 is the v1 entry of the design-brief Ctrl+1..5 family. Ctrl+2..5 get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts Input keeps focus across the tab switch.

New Static(id="pane-name") widget alongside the existing identity + hint widgets. v1 renders the literal string "Tools" set once at on_mount; the widget never updates after that. Dynamic updating + tab-switch handler wiring is out of scope for #13 — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a @on(TabbedContent.TabActivated) handler in this issue is out of spec.

Invariants

  • INV-013: Layout is Horizontal two-column. Left column width = 2fr; right column width = 1fr.
  • INV-014: ToolStart / ToolResult events route to tools_log (Tools pane), never to log (transcript).
  • INV-015 (amended v0.5.0): The transcript (log) is content-only — receives Text, Done (label + Markdown body + Rule), Error, Cancelled, and the user-prompt echo ( <content>). All telemetry events (Thinking closed runs, WorkerPhase, TextBoundary) route to debug_log (Debug pane), NOT log. Live thinking deltas continue to update thinking_widget per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule.
  • INV-016: Input retains keyboard focus across Ctrl+1 / Ctrl+2 tab switches.
  • INV-017 (amended v0.5.0): thinking-current Static docks to the top of the right column (above TabbedContent), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
  • INV-018: CLI mode (ratatoskr.cli._amain) is unaffected. CLI keeps inline · tool_start: … / · tool_result: … rendering on stderr per issue #12 INV-005.
  • INV-019 (amended v0.6.0): Three TabPanes in the right column: Tools (id tools-tab, contains #tools-log) + Debug (id debug-tab, contains #debug-log) + Thinking (id thinking-tab, contains #thinking-log). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. pane-name Static reflects active tab name dynamically.
  • INV-020 (amended v0.6.0): Render-exception fallback (INV-009) preserves routing per event class: ToolStart / ToolResulttools_log; Thinkingthinking_log; WorkerPhase / TextBoundarydebug_log; everything else → log.
  • INV-021 (new v0.6.0): Text events do NOT route to log per-delta. They accumulate into TuiPresenterState.text_buffer and update a single current_text Static (docked above the prompt). On terminal event (Done/Error/Cancelled), current_text is cleared and (raw mode) accumulated text or (non-raw) post-Done Markdown(response) is written to log. The pre-v0.6.0 per-token RichLog spam is retired.
  • INV-022 (amended v0.7.1): Thinking deltas COALESCE on \n boundaries before writing to thinking_log. The first delta of a run writes Rule(title=f"turn N · thinking #K start"); subsequent deltas accumulate in TuiPresenterState.thinking_chunk_buffer; whenever the buffer contains \n, the leading line(s) flush as RichLog entries (one entry per natural paragraph). The run closes on the next non-thinking event: any tail in the buffer flushes as a final line, then Rule(title=f"turn N · thinking #K end"). Pre-v0.7.1 per-delta-per-line caused token-spam (Worldtree emits thinking at token granularity); coalescing produces one log line per natural paragraph, not per token.
  • INV-023 (new v0.6.0): Turn-ID header Rule(title=f"turn N") is written to all four log panes (log, tools_log, debug_log, thinking_log) by _stream_turn_worker on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging.
  • INV-024 (amended v0.6.5): thinking-current Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into thinking_log (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator.

TESTS (additions / changes to test_tui.py)

- test_compose_has_horizontal_main_row: RatatoskrApp.compose() yields a Horizontal with id="main-row" containing left-column + right-column children.
- test_compose_right_column_has_tabbed_content: query_one("#side-panes", TabbedContent) is non-None; one TabPane child with title="Tools" id="tools-tab".
- test_compose_left_column_has_transcript_input: query_one("#left-column", Vertical) contains #transcript (RichLog) + #prompt (Input).
- test_tools_log_present: query_one("#tools-log", RichLog) is non-None; lives inside the tools-tab TabPane.
- test_pane_name_widget_renders_tools: query_one("#pane-name", Static).renderable == "Tools" (v1 static).
- test_tool_start_routes_to_tools_log: stream_turn emits ToolStart → tools_log receives the line; transcript RichLog does NOT receive it.
- test_tool_result_routes_to_tools_log: stream_turn emits ToolResult → tools_log receives the line; transcript does NOT receive it.
- test_text_event_still_routes_to_transcript: stream_turn emits Text("hello") → transcript receives it; tools_log does NOT.
- test_thinking_event_still_routes_to_thinking_widget: thinking deltas continue to update #thinking-current Static, not tools_log.
- test_done_event_still_routes_to_transcript: Done event renders `[done] …` in transcript, not tools_log.
- test_ctrl_1_activates_tools_tab: simulate Ctrl+1 → TabbedContent.active == "tools-tab".
- test_ctrl_1_preserves_input_focus: simulate Ctrl+1 while Input is focused → Input is still focused afterwards.
- test_plain_label_fallback_routes_tool_events_to_tools_log: simulate render exception on a ToolStart → tools_log gets the _plain_label fallback string; transcript doesn't.

Existing tests that need adjustment (NOT rewrite):

  • Any test that asserted log.write(...) was called with a tool_start: … / tool_result: … string changes its target widget to tools_log instead.
  • TestAppMount tests gain tools_log widget lookup assertions.

ERROR_ROUTING (unchanged)

All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (SseConnectFailed, SseConnectionDropped, MalformedSseId, MalformedSseData, TurnIdFlip) all write their labeled lines to log (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch.

Layout-spec snapshot (after v0.5.0)

+────────────────────────────────+──────────────────────+
|   user-typed line             | · thinking-current   |
|  assistant streaming text...   |  ┌Tools─┬─Debug─────┐|
|  [done] turn_id=… duration=…   |  │ · tool_start:..  │|
|  …markdown render…             |  │ · tool_result..  │|
|                                |  │                  │|
|                                |  │                  │|
|                                |  │                  │|
|  [prompt: type and press Enter]|  └──────────────────┘|
+────────────────────────────────+──────────────────────+
| agent · …sess_id  Tools  Ctrl-C twice to exit         |
+───────────────────────────────────────────────────────+

Left column is content-only (transcript + prompt). Right column hosts the live thinking-current Static at top + TabbedContent cycling between Tools (tool events) and Debug (thinking closed runs + worker_phase + text_boundary).

(Width split 2fr:1fr; tab strip is Textual-default; Header/Footer backgrounds explicitly set to $surface to override the Textual default $primary-blue tinting.)