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.
21 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.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. |
|
python | medium | 180 | 0.85 |
|
|
|
|
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/ToolResultevents route totools_log: RichLog(Tools pane) instead of the main transcriptlog: 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>totools_log(notlog).ToolResult→ write· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>totools_log(notlog).- All other events → unchanged routing per issue #12 INV-005.
- Render-exception fallback (
_plain_label(event)): write totools_logif the event isToolStart/ToolResult; write tologotherwise. 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.
Status footer (amendment)
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
Horizontaltwo-column. Left column width = 2fr; right column width = 1fr. - INV-014:
ToolStart/ToolResultevents route totools_log(Tools pane), never tolog(transcript). - INV-015 (amended v0.5.0): The transcript (
log) is content-only — receivesText,Done(label + Markdown body + Rule),Error,Cancelled, and the user-prompt echo (❯ <content>). All telemetry events (Thinkingclosed runs,WorkerPhase,TextBoundary) route todebug_log(Debug pane), NOTlog. Live thinking deltas continue to updatethinking_widgetper-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+2tab switches. - INV-017 (amended v0.5.0):
thinking-currentStatic docks to the top of the right column (aboveTabbedContent), 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(idtools-tab, contains#tools-log) +Debug(iddebug-tab, contains#debug-log) +Thinking(idthinking-tab, contains#thinking-log). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs.pane-nameStatic reflects active tab name dynamically. - INV-020 (amended v0.6.0): Render-exception fallback (INV-009) preserves routing per event class:
ToolStart/ToolResult→tools_log;Thinking→thinking_log;WorkerPhase/TextBoundary→debug_log; everything else →log. - INV-021 (new v0.6.0):
Textevents do NOT route tologper-delta. They accumulate intoTuiPresenterState.text_bufferand update a singlecurrent_textStatic (docked above the prompt). On terminal event (Done/Error/Cancelled),current_textis cleared and (raw mode) accumulated text or (non-raw) post-DoneMarkdown(response)is written tolog. The pre-v0.6.0 per-token RichLog spam is retired. - INV-022 (amended v0.7.1): Thinking deltas COALESCE on
\nboundaries before writing tothinking_log. The first delta of a run writesRule(title=f"turn N · thinking #K start"); subsequent deltas accumulate inTuiPresenterState.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, thenRule(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_workeron the first event of each turn — enables cross-pane visual correlation during multi-turn debugging. - INV-024 (amended v0.6.5):
thinking-currentStatic 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 intothinking_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 atool_start: …/tool_result: …string changes its target widget totools_loginstead. - TestAppMount tests gain
tools_logwidget 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.)