Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3569904bc | |||
| 82437bd4b9 |
@@ -161,9 +161,9 @@ 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** *(new v0.6.0)*: Closed thinking runs route to `thinking_log`, NOT `debug_log`. Each closed run writes three entries: `Rule(title=f"turn N · thinking #K start")`, `Markdown(content)`, `Rule(title=f"turn N · thinking #K end")` — the model's chain-of-thought is presented as rendered Markdown (model reasoning often has lists / code / structure) wrapped in operator-visible start/end markers. `thinking_run_index` increments per-run within a turn.
|
||||
- **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-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.1)*: `thinking-current` Static lives INSIDE the Thinking TabPane (docked bottom, below `thinking-log`) — co-located with closed thinking runs so the operator sees streaming + history in one pane. Pre-v0.6.1 it sat above the TabbedContent (right-column header) which created a top/bottom discontinuity; the co-located shape resolves that. Trade-off: live thinking is now visible only when the Thinking tab is active (Ctrl+3). Prefix `thinking… ` self-identifies the widget contents.
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-24 (post-v0.6.3 Australis restored, $background → black):_
|
||||
_As of 2026-05-24 (post-v0.6.5 thinking streams into whole pane):_
|
||||
|
||||
**Status: v0.6.3 shipped.** Nine core issues complete (`sse_client`
|
||||
**Status: v0.6.5 shipped.** Nine core issues 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,8 +51,10 @@ 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.6.3 style(tui): restore Australis palette, only $background → pure black
|
||||
- `d845b20` style(tui): neutralize Australis dark palette — bg no longer blue-tinted (v0.6.2, reverted)
|
||||
- v0.6.5 refactor(tui): thinking streams into thinking-log (no Static)
|
||||
- `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4)
|
||||
- `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3)
|
||||
- `d845b20` style(tui): neutralize Australis dark palette (v0.6.2, reverted)
|
||||
- `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
|
||||
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
||||
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+55
-76
@@ -185,12 +185,11 @@ class TuiPresenterState:
|
||||
See `docs/contracts/issues/12.contract.md` for the full spec.
|
||||
"""
|
||||
|
||||
thinking_buffer: list[str] = field(default_factory=list)
|
||||
thinking_open: bool = False
|
||||
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
|
||||
# and update `current_text` Static in place — no per-token RichLog spam.
|
||||
text_buffer: list[str] = field(default_factory=list)
|
||||
# v0.6.0: thinking-run counter for turn-scoped start/end markers.
|
||||
# Thinking-run counter for turn-scoped start/end markers.
|
||||
thinking_run_index: int = 0
|
||||
|
||||
def render(
|
||||
@@ -198,7 +197,6 @@ class TuiPresenterState:
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
current_text: Static,
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
@@ -207,16 +205,18 @@ class TuiPresenterState:
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
v0.6.0 routing:
|
||||
v0.6.5 routing:
|
||||
- `log` (transcript) = content only: user-prompt echo (written
|
||||
outside the presenter), terminal labels, post-Done Markdown body.
|
||||
- `current_text` (Static below transcript) = live-streaming Text
|
||||
deltas accumulated into one growing line; cleared on terminal.
|
||||
- `tools_log` = ToolStart + ToolResult.
|
||||
- `debug_log` = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` = closed thinking runs (Markdown + start/end
|
||||
Rule markers); `thinking_widget` continues to receive live
|
||||
per-delta updates.
|
||||
- `thinking_log` = streaming Thinking deltas inline (each chunk =
|
||||
one line in the scrollable log). Rule(start)/Rule(end) markers
|
||||
wrap each run. The whole pane scrolls naturally — no separate
|
||||
tail-scrolling Static at the bottom (v0.6.5 removed
|
||||
`thinking-current`).
|
||||
|
||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
@@ -230,56 +230,40 @@ class TuiPresenterState:
|
||||
from rich.text import Text as RichText
|
||||
|
||||
def _dim(s: str) -> RichText:
|
||||
"""Wrap a demoted-telemetry line in Australis dark-60 grey.
|
||||
|
||||
v0.4.1 retheme: was `style="dim"` (terminal-dim filter, varies by
|
||||
emulator); now explicit Australis Sea dark-60 (#86929d) so the
|
||||
shade renders consistently across terminals and stays anchored to
|
||||
the brand palette.
|
||||
"""
|
||||
"""Wrap a demoted-telemetry line in Australis Sea dark-60 grey."""
|
||||
return RichText(s, style=_AU_DEMOTED)
|
||||
|
||||
try:
|
||||
# Thinking events: accumulate into buffer, update widget per delta.
|
||||
# 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).
|
||||
if isinstance(event, Thinking):
|
||||
from rich.rule import Rule
|
||||
|
||||
if not self.thinking_open:
|
||||
thinking_widget.display = True
|
||||
self.thinking_run_index += 1
|
||||
turn_id = event.sse_id.turn_id
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
self.thinking_open = True
|
||||
self.thinking_buffer.append(event.content)
|
||||
acc = "".join(self.thinking_buffer)
|
||||
# v0.5.1 polish: prefix the live widget with "thinking… " so
|
||||
# operators recognize what the streaming content is (otherwise
|
||||
# the static-content under Header reads like uncontextualized
|
||||
# spillover). Truncate display to last 200 chars + ellipsis.
|
||||
tail = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(f"thinking… {tail}")
|
||||
# Stream the delta content (chunk-of-tokens) as one line.
|
||||
thinking_log.write(event.content)
|
||||
return
|
||||
# Non-thinking event: close any open thinking run.
|
||||
# v0.6.0: closed thinking runs route to thinking_log (Thinking
|
||||
# pane) wrapped in `── turn N · thinking start/end ──` Rule
|
||||
# markers, with the content itself rendered as Markdown (model
|
||||
# reasoning often has lists, code, structure).
|
||||
# Non-thinking event: close any open thinking run with Rule(end).
|
||||
if self.thinking_open:
|
||||
full_thinking = "".join(self.thinking_buffer)
|
||||
from rich.rule import Rule
|
||||
|
||||
turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else (
|
||||
event.turn_id if hasattr(event, "turn_id") else "?"
|
||||
)
|
||||
self.thinking_run_index += 1
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
thinking_log.write(Markdown(full_thinking))
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
self.thinking_buffer.clear()
|
||||
self.thinking_open = False
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
# Now render the non-thinking event itself.
|
||||
if isinstance(event, Text):
|
||||
# v0.6.0: streaming text accumulates into current_text Static
|
||||
@@ -327,10 +311,6 @@ class TuiPresenterState:
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
))
|
||||
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
|
||||
# terminal event, even if thinking_open was False — per STEPS 5-6.
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
return
|
||||
if isinstance(event, WorkerPhase):
|
||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||
@@ -425,22 +405,34 @@ class AgentPickerApp(App[str | None]):
|
||||
background: $background;
|
||||
}
|
||||
/* Multi-line agent items. Each ListItem is auto-height so the full
|
||||
description wraps below the agent_id/name line — no truncation. */
|
||||
description wraps below the agent_id/name line — no truncation.
|
||||
v0.6.4: lock bg to $background so Textual's auto background-tint on
|
||||
focus doesn't bleed through unwanted color into the non-highlighted
|
||||
items. */
|
||||
#agent-list > ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
/* v0.6.1: override Textual's default ListView:focus highlight, which
|
||||
defaults to $primary (Aurora blue) and made the picker unreadable.
|
||||
Both selectors needed — focused state has higher specificity in
|
||||
Textual's defaults. */
|
||||
ListView > ListItem.--highlight,
|
||||
ListView:focus > ListItem.--highlight {
|
||||
background: $au-dark-30;
|
||||
/* v0.6.4: highlighted item gets Aurora blue background (Textual's
|
||||
default $block-cursor-background = $primary). Override only the
|
||||
text-color descendants so id-line/desc stay readable on blue. The
|
||||
background itself comes from Textual's default ListItem.-highlight
|
||||
rule — we removed our previous overriding selectors.
|
||||
|
||||
Textual's class is `-highlight` (single dash). Use plain descendant
|
||||
combinator to bypass internal DOM wrappers. */
|
||||
#agent-list:focus ListItem.-highlight {
|
||||
background: $primary;
|
||||
}
|
||||
/* Children of highlighted items keep their colors — the dark-30 bg
|
||||
provides enough contrast for bright-blue id + dark-60 desc text. */
|
||||
#agent-list:focus ListItem.-highlight .agent-id-line {
|
||||
color: $au-bright-white;
|
||||
text-style: bold;
|
||||
}
|
||||
#agent-list:focus ListItem.-highlight .agent-desc {
|
||||
color: $au-bright-80;
|
||||
}
|
||||
/* Default (unhighlighted) item text styling. */
|
||||
.agent-id-line {
|
||||
color: $au-bright-blue;
|
||||
text-style: bold;
|
||||
@@ -552,18 +544,8 @@ class RatatoskrApp(App[int]):
|
||||
#right-column {
|
||||
width: 1fr;
|
||||
}
|
||||
/* v0.6.1: thinking-current Static moved INTO the Thinking pane (below
|
||||
thinking-log) so streaming + closed runs co-locate. Docked bottom of
|
||||
its TabPane so it acts as the live "tail" of the chronological log
|
||||
above. Empty (height:0) when no thinking is active. */
|
||||
#thinking-current {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
text-style: italic;
|
||||
background: $background;
|
||||
}
|
||||
/* v0.6.5: thinking-current Static removed; thinking now streams
|
||||
directly into thinking-log so the whole pane scrolls naturally. */
|
||||
#transcript {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
@@ -691,14 +673,15 @@ class RatatoskrApp(App[int]):
|
||||
id="debug-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
with TabPane("Thinking", id="thinking-tab"):
|
||||
# v0.6.5: thinking streams directly into this
|
||||
# RichLog (no separate bottom Static). Each delta
|
||||
# writes a line; Rule(start)/Rule(end) mark run
|
||||
# boundaries. The whole pane scrolls naturally
|
||||
# as content arrives — no more "200-char tail
|
||||
# window scrolling at the bottom".
|
||||
yield RichLog(
|
||||
id="thinking-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
# v0.6.1: live thinking lives INSIDE the Thinking
|
||||
# pane (docked bottom) — co-located with the closed
|
||||
# runs in thinking-log above. No more top/bottom
|
||||
# discontinuity across the right column.
|
||||
yield Static("", id="thinking-current")
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
@@ -717,8 +700,6 @@ class RatatoskrApp(App[int]):
|
||||
identity = f"{agent_slot} · …{self.session_id[-8:]}"
|
||||
self.sub_title = identity # mirror to Header subtitle for redundancy
|
||||
self.query_one("#identity", Static).update(identity)
|
||||
# Issue #12: thinking widget hidden until a thinking event fires.
|
||||
self.query_one("#thinking-current", Static).display = False
|
||||
# v0.5.1 polish: empty-state placeholder lines so the operator sees
|
||||
# the pane is intentionally empty (not broken) before any turn fires.
|
||||
# Wrapped in Australis dark-50 italic so they read distinctly as
|
||||
@@ -799,7 +780,6 @@ class RatatoskrApp(App[int]):
|
||||
assert self.client is not None
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
thinking_widget = self.query_one("#thinking-current", Static)
|
||||
current_text = self.query_one("#current-text", Static)
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
debug_log = self.query_one("#debug-log", RichLog)
|
||||
@@ -816,7 +796,6 @@ class RatatoskrApp(App[int]):
|
||||
presenter.render(
|
||||
event,
|
||||
log=log,
|
||||
thinking_widget=thinking_widget,
|
||||
current_text=current_text,
|
||||
tools_log=tools_log,
|
||||
debug_log=debug_log,
|
||||
|
||||
+84
-193
@@ -11,7 +11,6 @@ from ratatoskr.cli import ParsedArgs
|
||||
from ratatoskr.sse_client import (
|
||||
Cancelled,
|
||||
Done,
|
||||
Error,
|
||||
SseId,
|
||||
Text,
|
||||
Thinking,
|
||||
@@ -116,56 +115,43 @@ SID = SseId(42, 5)
|
||||
class TestTuiPresenterState:
|
||||
"""Tests for the new TuiPresenterState — per issue #12 contract."""
|
||||
|
||||
def test_thinking_coalesce_single_widget_update(self) -> None:
|
||||
"""thinking_coalesce_single_widget_update [happy,tracer]:
|
||||
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
|
||||
RichLog has 0 thinking entries (closure hasn't fired yet).
|
||||
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.
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="a"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="b"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="c"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# Widget updated 3 times — once per delta — with cumulative content
|
||||
assert widget.update.call_count == 3
|
||||
# Latest call shows the full accumulated content (under 200 chars so no truncation).
|
||||
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
|
||||
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
|
||||
# Widget became visible at first delta
|
||||
assert widget.display is True
|
||||
# No RichLog write yet — closure hasn't fired
|
||||
for chunk in ("a", "b", "c"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=chunk),
|
||||
log=log,
|
||||
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]
|
||||
# 1 Rule(start) + 3 content lines = 4 writes
|
||||
assert len(writes) == 4
|
||||
assert isinstance(writes[0], Rule)
|
||||
assert writes[1] == "a"
|
||||
assert writes[2] == "b"
|
||||
assert writes[3] == "c"
|
||||
# Transcript untouched during thinking streaming.
|
||||
assert log.write.call_count == 0
|
||||
|
||||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||||
"""thinking_closes_to_thinking_log [happy, v0.6.0]: 2x Thinking + WorkerPhase →
|
||||
thinking_log gets Rule(start) + Markdown + Rule(end); debug_log gets worker_phase;
|
||||
transcript and tools_log untouched. Widget cleared+hidden.
|
||||
"""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.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
@@ -173,13 +159,11 @@ class TestTuiPresenterState:
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for content in ("a", "b"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=content),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
@@ -189,87 +173,32 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
# v0.6.0: closure writes Rule(start) + Markdown + Rule(end) to thinking_log.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
assert any(isinstance(w, Rule) for w in thinking_writes), thinking_writes
|
||||
assert any(isinstance(w, Markdown) for w in thinking_writes), thinking_writes
|
||||
# worker_phase still goes to debug_log; transcript still untouched.
|
||||
assert debug_log.write.called
|
||||
# 1 Rule(start) + 2 delta lines + 1 Rule(end) = 4 writes
|
||||
assert len(thinking_writes) == 4
|
||||
assert isinstance(thinking_writes[0], Rule)
|
||||
assert thinking_writes[1] == "a"
|
||||
assert thinking_writes[2] == "b"
|
||||
assert isinstance(thinking_writes[3], 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
|
||||
# Widget cleared + hidden
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False
|
||||
|
||||
def test_thinking_widget_truncation(self) -> None:
|
||||
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "…" + last 200."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
# Push 500 chars across multiple deltas.
|
||||
long = "x" * 500
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=long),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
last_update = widget.update.call_args_list[-1][0][0]
|
||||
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||||
assert last_update.startswith("thinking… ")
|
||||
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
|
||||
assert len(last_update) == len("thinking… ") + 201
|
||||
assert "…" in last_update
|
||||
|
||||
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
||||
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
||||
hidden after closing event.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.display = False # initial state (composed hidden)
|
||||
state = TuiPresenterState()
|
||||
# First thinking delta → widget visible
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert widget.display is True
|
||||
# Closure (WorkerPhase) → widget hidden
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert widget.display is False
|
||||
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
|
||||
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
||||
|
||||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||||
"""multiple_thinking_runs_each_get_thinking_log_section [scenario, v0.6.0]:
|
||||
Thinking → Text → Thinking → Done → TWO start/end Rule + Markdown sections
|
||||
in thinking_log. Text goes to current_text Static (buffered). Transcript
|
||||
receives [done] label + Markdown body only.
|
||||
"""multiple_thinking_runs_each_get_section [scenario, v0.6.5]:
|
||||
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
|
||||
thinking_log, each wrapping their delta lines. Text goes to
|
||||
current_text (buffered). Transcript: [done] + Markdown body.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
@@ -277,7 +206,6 @@ class TestTuiPresenterState:
|
||||
log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for evt in (
|
||||
Thinking(sse_id=SID, content="first"),
|
||||
@@ -285,22 +213,23 @@ class TestTuiPresenterState:
|
||||
Thinking(sse_id=SID, content="second"),
|
||||
):
|
||||
state.render(
|
||||
evt, log=log, thinking_widget=widget,
|
||||
evt, log=log,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log, thinking_widget=widget,
|
||||
log=log,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
|
||||
# v0.6.5: thinking_log holds 4 Rules (start + end per run) + 2 delta lines.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||||
markdowns = [w for w in thinking_writes if isinstance(w, Markdown)]
|
||||
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
|
||||
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||||
assert len(markdowns) == 2, f"expected 2 Markdown sections, got {len(markdowns)}"
|
||||
assert "first" in delta_strs
|
||||
assert "second" in delta_strs
|
||||
# Text "hi" went to current_text (buffered), not the transcript directly.
|
||||
current_text.update.assert_any_call("hi")
|
||||
# Transcript: [done] label + Markdown(response) (raw=False).
|
||||
@@ -308,22 +237,25 @@ class TestTuiPresenterState:
|
||||
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||
|
||||
def test_render_exception_fallback(self) -> None:
|
||||
"""render_exception_fallback [adversarial, v0.6.0]:
|
||||
widget.update raises → thinking_log gets the plain-label fallback for
|
||||
Thinking (per v0.6.0 routing — Thinking now routes to thinking_log,
|
||||
not debug_log). render_error line follows. transcript untouched.
|
||||
"""render_exception_fallback [adversarial, v0.6.5]:
|
||||
thinking_log.write raises → catch in presenter, write plain-label
|
||||
fallback + render_error line via INV-009 fallback path (routing
|
||||
preservation: thinking events still route to thinking_log).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||||
# First call (Rule write) raises; subsequent calls succeed for fallback.
|
||||
thinking_log.write.side_effect = [
|
||||
AttributeError("rule write failed (msg should NOT leak)"),
|
||||
None,
|
||||
None,
|
||||
]
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
@@ -333,7 +265,7 @@ class TestTuiPresenterState:
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
|
||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
||||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||||
assert not any("widget gone" in w for w in writes), writes
|
||||
assert not any("rule write failed" in w for w in writes), writes
|
||||
assert not log.write.called
|
||||
|
||||
def test_state_reset_per_worker(self) -> None:
|
||||
@@ -344,10 +276,11 @@ class TestTuiPresenterState:
|
||||
s1.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=MagicMock(),
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
s2 = TuiPresenterState()
|
||||
assert s1.thinking_open is True
|
||||
@@ -361,32 +294,29 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="partial"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
||||
),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
|
||||
# Rule start/end). terminal [cancelled] still in transcript.
|
||||
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert any(w.startswith("[cancelled]") for w in log_writes)
|
||||
assert widget.display is False
|
||||
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
|
||||
assert thinking_log.write.call_count >= 3
|
||||
|
||||
def test_done_renders_markdown_after_label(self) -> None:
|
||||
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
||||
@@ -401,12 +331,10 @@ class TestTuiPresenterState:
|
||||
|
||||
log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
@@ -418,7 +346,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
@@ -440,12 +367,10 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
@@ -453,7 +378,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
@@ -477,7 +401,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
@@ -494,36 +417,10 @@ class TestTuiPresenterState:
|
||||
assert text.startswith("· worker_phase:")
|
||||
assert "[worker_phase]" not in text
|
||||
|
||||
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
|
||||
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
|
||||
Done / Error / Cancelled MUST clear+hide the thinking widget even when
|
||||
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
for terminal in (
|
||||
_make_tui_done(),
|
||||
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
|
||||
),
|
||||
):
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.display = True # pre-set to non-default to detect the clear
|
||||
state = TuiPresenterState()
|
||||
# thinking_open is False (state just constructed).
|
||||
state.render(
|
||||
terminal,
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False, type(terminal).__name__
|
||||
# v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted.
|
||||
# The thinking-current Static is gone, so there's no widget to clean up
|
||||
# on terminal events. The corresponding Volva F3 invariant is obsoleted
|
||||
# by the streaming-into-thinking_log architecture.
|
||||
|
||||
def test_tool_start_routes_to_tools_log(self) -> None:
|
||||
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
|
||||
@@ -540,7 +437,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
@@ -561,7 +457,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
@@ -583,7 +478,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hello"),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
@@ -606,7 +500,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
Text(sse_id=SID, content=tok),
|
||||
log=MagicMock(),
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
@@ -625,7 +518,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
_make_tui_done(duration_ms=5467),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
@@ -653,7 +545,6 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
_make_tui_done(usage=usage),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
@@ -815,27 +706,28 @@ class TestLayoutShape:
|
||||
assert row is not None
|
||||
|
||||
async def test_left_column_content_only(self) -> None:
|
||||
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
|
||||
|
||||
thinking-current Static moved to right column so the left column is
|
||||
genuinely content-only (transcript + prompt input).
|
||||
"""left_column_content_only [v0.6.5]: left column = transcript + prompt
|
||||
+ current-text (streaming text Static). thinking-current Static
|
||||
removed entirely as of v0.6.5.
|
||||
"""
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Input, RichLog, Static
|
||||
from textual.widgets import Input, RichLog
|
||||
|
||||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
left = app.query_one("#left-column", Vertical)
|
||||
right = app.query_one("#right-column", Vertical)
|
||||
transcript = app.query_one("#transcript", RichLog)
|
||||
prompt = app.query_one("#prompt", Input)
|
||||
thinking = app.query_one("#thinking-current", Static)
|
||||
assert transcript in left.walk_children()
|
||||
assert prompt in left.walk_children()
|
||||
# v0.5.0: thinking-current is now under the right column, NOT left.
|
||||
assert thinking not in left.walk_children()
|
||||
assert thinking in right.walk_children()
|
||||
# v0.6.5: thinking-current Static removed; no longer in DOM at all.
|
||||
from textual.css.query import NoMatches
|
||||
try:
|
||||
app.query_one("#thinking-current")
|
||||
raise AssertionError("thinking-current should not exist in v0.6.5")
|
||||
except NoMatches:
|
||||
pass # expected
|
||||
|
||||
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
|
||||
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
|
||||
@@ -940,7 +832,6 @@ class TestLayoutShape:
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=app.query_one("#thinking-current"),
|
||||
tools_log=app.query_one("#tools-log", RichLog),
|
||||
debug_log=app.query_one("#debug-log", RichLog),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
|
||||
Reference in New Issue
Block a user