From 9918c10acff8f94e04b97cdefe6048c9fbc9bc93 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 24 May 2026 20:39:55 -0700 Subject: [PATCH] fix(tui): coalesce thinking deltas on `\n` (v0.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/contracts/issues/13.contract.md | 2 +- persistent-memory.md | 7 ++-- pyproject.toml | 2 +- src/ratatoskr/tui.py | 30 +++++++++++--- tests/test_tui.py | 60 +++++++++++++++++++--------- uv.lock | 2 +- 6 files changed, 72 insertions(+), 31 deletions(-) diff --git a/docs/contracts/issues/13.contract.md b/docs/contracts/issues/13.contract.md index 0312eb0..3b796b4 100644 --- a/docs/contracts/issues/13.contract.md +++ b/docs/contracts/issues/13.contract.md @@ -161,7 +161,7 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w - **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` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_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.6.5)*: Thinking deltas stream DIRECTLY into `thinking_log` (one delta = one RichLog line). The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas write their raw content as lines; the run closes on the next non-thinking event with `Rule(title=f"turn N · thinking #K end")`. Pre-v0.6.5 markdown re-render dropped — the streamed deltas ARE the content; the whole pane scrolls naturally as content arrives. +- **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. diff --git a/persistent-memory.md b/persistent-memory.md index 9bbbfc4..1f95a9c 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool. ## Current state / in-flight -_As of 2026-05-25 (post-v0.7.0 Tier 3 agent lifecycle):_ +_As of 2026-05-25 (post-v0.7.1 thinking coalesce-by-newline):_ -**Status: v0.7.0 shipped.** Ten core features complete (`sse_client` +**Status: v0.7.1 shipped.** Ten core features complete (`sse_client` #1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI startup error visibility #6, presenter contract semantics amendment #12, startup agent picker #8, §5 layout reshape + Tools pane #13) @@ -51,7 +51,8 @@ Static in the footer (static "Tools" v1; dynamic when more tabs land). CLI mode (--send) unaffected by design — INV-018. Last commits on `main`: -- v0.7.0 feat(tier3): ratatoskr.tier3 module + CLI — Worldtree Tier 3 lifecycle +- v0.7.1 fix(tui): coalesce thinking deltas on `\n` — no more per-token newlines +- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0) - `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5) - `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4) - `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3) diff --git a/pyproject.toml b/pyproject.toml index 62b60c1..c2c4d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.7.0" +version = "0.7.1" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index f65fc57..751db09 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -191,6 +191,12 @@ class TuiPresenterState: text_buffer: list[str] = field(default_factory=list) # Thinking-run counter for turn-scoped start/end markers. thinking_run_index: int = 0 + # v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas + # at token granularity; flushing each delta as its own RichLog line + # produces per-token-per-newline visual spam. Buffer here and flush + # only on `\n` boundaries (one written line per natural paragraph) or + # when the run closes (any leftover tail). + thinking_chunk_buffer: str = "" def render( self, @@ -234,10 +240,11 @@ class TuiPresenterState: return RichText(s, style=_AU_DEMOTED) try: - # v0.6.5: Thinking deltas stream directly into thinking_log. - # First delta of a run writes the Rule(start) header; each - # subsequent delta writes its content as a line; the run closes - # on the next non-thinking event with a Rule(end). + # v0.7.1: Thinking deltas coalesce by newline before flushing. + # Worldtree emits Thinking events at token granularity; per-delta + # RichLog writes produce one visual line per token (per-token-per- + # newline spam). Buffer the deltas and flush only on `\n` (one + # written line per natural paragraph) or run close. if isinstance(event, Thinking): from rich.rule import Rule @@ -249,13 +256,24 @@ class TuiPresenterState: style=_AU_DEMOTED, )) self.thinking_open = True - # Stream the delta content (chunk-of-tokens) as one line. - thinking_log.write(event.content) + self.thinking_chunk_buffer += event.content + # Flush every complete line in the buffer. Whatever's after + # the final `\n` stays buffered for the next delta or close. + while "\n" in self.thinking_chunk_buffer: + line, _, rest = self.thinking_chunk_buffer.partition("\n") + if line: # skip empty lines (blank paragraph separators) + thinking_log.write(line) + self.thinking_chunk_buffer = rest return # Non-thinking event: close any open thinking run with Rule(end). if self.thinking_open: from rich.rule import Rule + # Flush the tail (content with no trailing `\n`) before the + # end rule so nothing gets lost on close. + if self.thinking_chunk_buffer: + thinking_log.write(self.thinking_chunk_buffer) + self.thinking_chunk_buffer = "" turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else ( event.turn_id if hasattr(event, "turn_id") else "?" ) diff --git a/tests/test_tui.py b/tests/test_tui.py index f666b45..5513db8 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -115,10 +115,11 @@ SID = SseId(42, 5) class TestTuiPresenterState: """Tests for the new TuiPresenterState — per issue #12 contract.""" - def test_thinking_streams_into_thinking_log(self) -> None: - """thinking_streams_into_thinking_log [happy,tracer, v0.6.5]: - 3 Thinking deltas → thinking_log gets Rule(start) + 3 delta lines. - Transcript untouched; no thinking-current Static involved. + def test_thinking_coalesces_until_newline(self) -> None: + """thinking_coalesces_until_newline [happy,tracer, v0.7.1]: + Per-token deltas accumulate in the buffer; flush only on `\\n`. + Three short token-shaped deltas without `\\n` → thinking_log gets + ONLY Rule(start); content stays buffered. """ from rich.rule import Rule @@ -127,7 +128,7 @@ class TestTuiPresenterState: log = MagicMock() thinking_log = MagicMock() state = TuiPresenterState() - for chunk in ("a", "b", "c"): + for chunk in ("Let", " me", " think"): state.render( Thinking(sse_id=SID, content=chunk), log=log, @@ -138,19 +139,41 @@ class TestTuiPresenterState: raw=False, ) writes = [c[0][0] for c in thinking_log.write.call_args_list] - # 1 Rule(start) + 3 content lines = 4 writes - assert len(writes) == 4 + # Only Rule(start) — content stays buffered (no `\n` seen). + assert len(writes) == 1 assert isinstance(writes[0], Rule) - assert writes[1] == "a" - assert writes[2] == "b" - assert writes[3] == "c" - # Transcript untouched during thinking streaming. + assert state.thinking_chunk_buffer == "Let me think" assert log.write.call_count == 0 + def test_thinking_flushes_on_newline(self) -> None: + """thinking_flushes_on_newline [happy, v0.7.1]: + Delta carrying `\\n` flushes the accumulated buffer as ONE line. + """ + from ratatoskr.tui import TuiPresenterState + + thinking_log = MagicMock() + state = TuiPresenterState() + for chunk in ("Hello", " world", "\n"): + state.render( + Thinking(sse_id=SID, content=chunk), + log=MagicMock(), + tools_log=MagicMock(), + debug_log=MagicMock(), + current_text=MagicMock(), + thinking_log=thinking_log, + raw=False, + ) + writes = [c[0][0] for c in thinking_log.write.call_args_list] + # Rule(start) + "Hello world" (one coalesced line) = 2 writes + assert len(writes) == 2 + assert writes[1] == "Hello world" + assert state.thinking_chunk_buffer == "" + def test_thinking_closes_to_thinking_log(self) -> None: - """thinking_closes_to_thinking_log [happy, v0.6.5]: 2x Thinking + WorkerPhase → - thinking_log gets Rule(start) + 2 delta lines + Rule(end); debug_log gets - the worker_phase line; transcript untouched. + """thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase → + v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes + "ab" as a single line before Rule(end). Result: Rule(start) + "ab" + + Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched. """ from rich.rule import Rule @@ -180,12 +203,11 @@ class TestTuiPresenterState: raw=False, ) thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list] - # 1 Rule(start) + 2 delta lines + 1 Rule(end) = 4 writes - assert len(thinking_writes) == 4 + # v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes + assert len(thinking_writes) == 3 assert isinstance(thinking_writes[0], Rule) - assert thinking_writes[1] == "a" - assert thinking_writes[2] == "b" - assert isinstance(thinking_writes[3], Rule) + assert thinking_writes[1] == "ab" + assert isinstance(thinking_writes[2], Rule) # worker_phase still goes to debug_log; transcript untouched. assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0]) assert not log.write.called diff --git a/uv.lock b/uv.lock index 3420a39..065dac0 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.7.0" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "httpx" },