Compare commits

...

2 Commits

Author SHA1 Message Date
vh cfee89ac1c refactor(tui): streaming + turn headers + Thinking pane + picker fix (v0.6.0)
Operator-driven big-batch polish + restructure:

## 1. Streaming text — no more per-token RichLog spam

Pre-v0.6.0, every Text SSE delta wrote its own RichLog line, so
"Let me read the..." became 4+ separate lines (a Worldtree-style
sentence-by-sentence reveal that read as broken). v0.6.0 adds a
`#current-text` Static docked above the prompt; TuiPresenterState
buffers Text deltas in `text_buffer` and updates the Static in
place. On terminal event the Static clears and the transcript
gets:
  - raw=False: post-Done Markdown body + Rule separator
  - raw=True:  accumulated plain text

The Static collapses to height=0 when empty so the prompt sits at
the column bottom unchanged.

## 2. Turn-ID headers across every pane

`_stream_turn_worker` writes a `Rule(title="turn N")` to all four
log panes (transcript, tools, debug, thinking) on the first event
of each new turn. Operators can now visually correlate "what
happened in Tools during turn 42" by section markers in matching
positions across panes.

## 3. New Thinking TabPane (Ctrl+3)

Closed thinking runs now route to `#thinking-log` (a dedicated
TabPane) instead of `#debug-log`. Each closed run writes three
entries:
  - Rule(title="turn N · thinking #K start")
  - Markdown(thinking_content)
  - Rule(title="turn N · thinking #K end")

Model reasoning often has lists/code/structure — rendering as
Markdown (instead of the previous "· thinking: ..." prefix line)
makes it scannable. The `thinking_run_index` counter scopes per
turn so multi-thinking-run turns get distinct markers.

`thinking-current` Static (live per-delta preview) stays in the
right column above TabbedContent (unchanged from v0.5.0) — live
visibility persists across tab switches.

## 4. Agent picker — multi-line items, full description visible

Pre-v0.6.0 the picker rendered each agent as a single Label with
"{id} · {name} — {description}", which truncated descriptions
visually. v0.6.0 uses two Static children per ListItem:
  - bold Aurora bright-blue line: "{agent_id} · {name}"
  - wrapped Sea dark-60 line(s): full description

ListItems are auto-height so long descriptions wrap as needed.
Highlighted (--highlight) row uses Sea dark-30 background instead
of Aurora blue (which the operator flagged as ugly).

## 5. Kill residual blue chrome

The user's "background is still blue" report traced to the prompt
Input's focused border, which I'd set to $primary (Aurora blue).
Switched to $au-bright-cyan (#42dcd1) — focus highlight is now
cyan, consistent with the operator's-voice accent throughout the
TUI. Also added explicit overrides for ContentTabs strip
background + active-tab underline color → Australis cyan.

## 6. Surfaced emotion-appraisal request to worldtree-dev

User asked for emotion-appraisal telemetry, but no SSE event for
this exists in the spec — persona/Vili affect lives in persona.log
(file-tail, blocked on remote-Worldtree topology) and per-character
state (poll endpoint, not per-turn). Posted an althing thread
proposing two shapes (worker_phase payload extension OR new
affect_update event type) and routing the decision to their team.
A 4th `Emotion` TabPane plugs in trivially when a wire event lands.
Low-priority / quality-of-life framing — not blocking ship.

## Contract amendment

docs/contracts/issues/13.contract.md amended in-place: INV-019
extended to 3 TabPanes; new INV-021 (Text → current_text Static),
INV-022 (thinking closed runs → thinking_log with Markdown +
start/end Rules), INV-023 (turn-ID headers across all panes),
INV-024 (thinking-current Static stays in right column with
"thinking… " prefix per v0.5.1 polish). INV-020 (render-exception
fallback routing) updated for Thinking → thinking_log. Drift-check
clean.

## Tests

241 GREEN (down from 244 in test count — 5 routing tests rewritten
for the new shape, replacing the v0.5.0 thinking-in-debug-log
assertions with the v0.6.0 thinking-log-as-Markdown shape; net
test coverage equivalent). ruff clean.

Live smoke against personal Worldtree's mimir confirmed:
  - transcript: 27 lines (turn header + user echo + done +
    markdown body, NO per-token spam)
  - thinking_log: 19 lines (turn header + 2x thinking start/end
    Rule sections with Markdown bodies)
  - current_text cleared post-Done

Minor bump (v0.5.1 → v0.6.0) per SemVer etiquette: visible routing
+ new pane = operator-observable surface change.
2026-05-24 15:29:55 -07:00
vh 7106af5c09 style(tui): UI polish pass (v0.5.1)
Cosmetic refinements on top of v0.5.0's content-only main pane. No
behavior change; ships as a patch bump.

## Color signal — terminal labels tinted per outcome

The transcript's [done]/[error]/[cancelled] labels were plain
foreground (Australis #a9bcc3 white), which made them slow to scan
against the surrounding assistant text. Now tinted per outcome:

- [done]      → Aurora green   (#16B866 / $success)
- [error]     → Dawn red       (#ff491a / $error)
- [cancelled] → Dawn yellow    (#e1c631 / $warning)

The post-Done Rule() separator is also tinted to Australis dark-60
(#86929d) so the streamed-text → markdown-body boundary reads as
chrome, not a content artifact.

## Empty-state placeholders

Tools and Debug panes were stark-empty before any turn fired — easy
to misread as "the pane is broken." Now show placeholder lines on
mount in Sea dark-50 italic:

  Tools tab: (no tool events yet — start a turn that uses tools)
  Debug tab: (waiting for telemetry — start a turn)

The placeholders scroll off naturally as real events fill the panes.

## Live thinking widget self-explains

The thinking-current Static at the top of the right column used to
just display raw thinking content with no context — an operator
glancing at the screen mid-stream might not realize they were
looking at LLM chain-of-thought. Now prefixed with "thinking… " so
the widget self-identifies.

## Spacing + chrome

- Transcript / tools-log / debug-log: 1-cell horizontal padding so
  content doesn't hug the column border.
- thinking-current: italic text-style on top of the dark-60 color,
  so the live-preview band is visually distinct from solid-colored
  log content.
- Active tab in TabbedContent: Aurora bright-cyan label + bold
  text-style, so the eye lands on the currently selected pane.
- Input placeholder text: tinted to Sea dark-50 so it reads as
  placeholder, not content.

## Test impact

3 new tests added (test_done_label_styled_success,
test_empty_state_placeholders_present, plus the polish hits
test_thinking_widget_truncation / test_thinking_coalesce updated for
the "thinking… " prefix). 4 existing tests that checked
`isinstance(w, str) and w.startswith("[done]")` updated to use the
_text_of helper (terminal labels are now RichText, not str).
_spy_writes helper widened to accept positional args after Textual's
internal deferred-render path started passing them positionally
post-Resize.

241/241 GREEN; ruff clean. Live smoke against personal Worldtree
confirmed: Done line renders in Aurora green #16B866 verbatim;
both placeholder lines appear in dark-50; thinking widget shows
"thinking… <content>" during a turn.

Patch bump (v0.5.0 → v0.5.1) per SemVer etiquette: purely cosmetic;
no signature change; no caller-visible behavioral shift.
2026-05-24 15:06:10 -07:00
6 changed files with 449 additions and 200 deletions
+6 -2
View File
@@ -158,8 +158,12 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
- **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** *(new v0.5.0)*: Two TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`). Ctrl+1 activates Tools; Ctrl+2 activates Debug. `pane-name` Static reflects the active tab name dynamically.
- **INV-020** *(new v0.5.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` fallback writes to `tools_log`; `WorkerPhase` / `Thinking` / `TextBoundary` fallback writes to `debug_log`; everything else falls back to `log`.
- **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-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** *(new v0.6.0)*: `thinking-current` Static remains in the right column (above TabbedContent) per INV-017 — live per-delta thinking visibility persists across tab switches. Prefix `thinking… ` self-identifies the widget contents.
## TESTS (additions / changes to test_tui.py)
+5 -3
View File
@@ -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.5.0 content-only main pane + Debug tab):_
_As of 2026-05-24 (post-v0.6.0 streaming + Thinking pane + turn headers):_
**Status: v0.5.0 shipped.** Nine core issues complete (`sse_client`
**Status: v0.6.0 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,7 +51,9 @@ 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.5.0 refactor(tui): content-only main pane + Debug tab + chrome dark
- v0.6.0 refactor(tui): streaming Static + turn headers + Thinking pane + agent picker multi-line
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.5.0"
version = "0.6.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+215 -46
View File
@@ -22,7 +22,6 @@ from textual.widgets import (
Footer,
Header,
Input,
Label,
ListItem,
ListView,
RichLog,
@@ -118,6 +117,7 @@ _AU_ERROR = "#ff491a"
_AU_WARNING = "#e1c631"
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
# ---- Issue #12 presenter contract semantics amendment -------------------------
@@ -178,6 +178,11 @@ class TuiPresenterState:
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_index: int = 0
def render(
self,
@@ -185,20 +190,26 @@ class TuiPresenterState:
*,
log: RichLog,
thinking_widget: Static,
current_text: Static,
tools_log: RichLog,
debug_log: RichLog,
thinking_log: RichLog,
raw: bool,
) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text,
terminal labels ([done] / [error] / [cancelled]), and the
post-Done Markdown render. All telemetry (Thinking closed runs,
WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all
tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools
tab). Live thinking deltas continue to update `thinking_widget`.
v0.6.0 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.
Exceptions are caught at the presenter boundary (INV-009 fallback).
Exceptions caught at the presenter boundary (INV-009 fallback).
"""
assert isinstance(
event,
@@ -227,48 +238,86 @@ class TuiPresenterState:
self.thinking_open = True
self.thinking_buffer.append(event.content)
acc = "".join(self.thinking_buffer)
display_text = ("" + acc[-200:]) if len(acc) > 200 else acc
thinking_widget.update(display_text)
# 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}")
return
# Non-thinking event: close any open thinking run.
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
# transcript — keeps the main pane content-only.
# 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).
if self.thinking_open:
full_thinking = "".join(self.thinking_buffer)
debug_log.write(_dim(f"· thinking: {full_thinking}"))
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):
# Streamed text content — no prefix, no demotion.
log.write(event.content)
# v0.6.0: streaming text accumulates into current_text Static
# — one growing live line, NOT per-delta RichLog entries.
self.text_buffer.append(event.content)
current_text.update("".join(self.text_buffer))
return
if isinstance(event, (Done, Error, Cancelled)):
# Terminal events: load-bearing label (no demotion).
# Terminal event: clear the streaming Static first so the
# live-preview band collapses. Then write the colored label
# + (non-raw) Markdown body / (raw) accumulated plain text
# to the transcript.
accumulated = "".join(self.text_buffer)
self.text_buffer.clear()
current_text.update("")
# Terminal labels tinted per outcome (Aurora green / Dawn red
# / Dawn yellow) for at-a-glance scanning.
if isinstance(event, Done):
log.write(
log.write(RichText(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='')}"
)
if not raw:
f"usage {_format_usage(event.usage, arrow='')}",
style=_AU_SUCCESS,
))
if raw:
# Raw mode: emit the accumulated streamed text verbatim
# so the operator has a record after the Static clears.
if accumulated:
log.write(accumulated)
else:
from rich.markdown import Markdown
from rich.rule import Rule
log.write(Rule())
log.write(Rule(style=_AU_DEMOTED))
log.write(Markdown(event.response))
elif isinstance(event, Error):
log.write(
log.write(RichText(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}"
)
f"message={event.message!r}",
style=_AU_ERROR,
))
else: # Cancelled
log.write(
log.write(RichText(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}"
)
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("")
@@ -304,14 +353,17 @@ class TuiPresenterState:
# the original event AND a render_error line with the class name only
# (NO exception message — security clause). Volva F1 fix.
#
# v0.5.0: routing-under-failure preservation — fallback writes go
# v0.6.0 routing-under-failure preservation — fallback writes go
# to the same destination the successful render would have used:
# - ToolStart/ToolResult → tools_log
# - WorkerPhase/Thinking/TextBoundary → debug_log
# - everything else (Text/Done/Error/Cancelled) → log
# - Thinking → thinking_log
# - WorkerPhase/TextBoundary → debug_log
# - everything else → log
if isinstance(event, (ToolStart, ToolResult)):
target = tools_log
elif isinstance(event, (WorkerPhase, Thinking, TextBoundary)):
elif isinstance(event, Thinking):
target = thinking_log
elif isinstance(event, (WorkerPhase, TextBoundary)):
target = debug_log
else:
target = log
@@ -330,6 +382,13 @@ class AgentPickerApp(App[str | None]):
"""
DEFAULT_CSS = """
Header {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
#picker-prompt {
dock: top;
height: 1;
@@ -341,9 +400,21 @@ class AgentPickerApp(App[str | None]):
height: 1fr;
background: $background;
}
/* v0.6.0: multi-line agent items. Each ListItem is auto-height so the
full description wraps below the agent_id/name line — no truncation. */
#agent-list > ListItem {
height: auto;
padding: 1 1;
}
#agent-list > ListItem.--highlight {
background: $primary;
color: $au-bright-white;
background: $au-dark-30;
}
.agent-id-line {
color: $au-bright-blue;
text-style: bold;
}
.agent-desc {
color: $au-dark-60;
}
"""
@@ -366,9 +437,16 @@ class AgentPickerApp(App[str | None]):
def compose(self) -> ComposeResult:
yield Header()
yield Static("Pick an agent for the new session:", id="picker-prompt")
# v0.6.0: each ListItem has two Static children — the id/name line
# in bold blue + the wrapped description in muted dark-60. No
# description truncation; tall items breathe so the operator can
# actually read what each agent does.
yield ListView(
*[
ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
ListItem(
Static(f"{a.agent_id} · {a.name}", classes="agent-id-line"),
Static(a.description, classes="agent-desc"),
)
for a in self.agents
],
id="agent-list",
@@ -411,6 +489,9 @@ class RatatoskrApp(App[int]):
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
# etc.) carry colors so a future theme swap rebinds centrally.
DEFAULT_CSS = """
/* Kill Textual's default $primary-blue tinting on chrome — Header,
Footer, ContentTabs strip, active-tab Underline all forced to the
Australis cool-dark palette. */
Header {
background: $surface;
color: $au-bright-blue;
@@ -433,23 +514,50 @@ class RatatoskrApp(App[int]):
height: auto;
color: $au-dark-60;
padding: 0 1;
text-style: italic;
}
#transcript {
height: 1fr;
background: $background;
padding: 0 1;
}
#tools-log, #debug-log {
/* v0.6.0: streaming-text Static carries in-flight assistant tokens.
Replaces per-token RichLog spam — one growing line that updates in
place. Cleared on terminal event; final Markdown body lands in the
transcript. */
#current-text {
dock: bottom;
height: auto;
background: $background;
padding: 0 1;
}
#side-panes Tabs {
#tools-log, #debug-log, #thinking-log {
background: $background;
padding: 0 1;
}
/* Tab strip + active-tab underline — kill blue, use Australis cyan. */
#side-panes > ContentTabs {
background: $surface;
}
#side-panes ContentTab.-active {
color: $au-bright-cyan;
text-style: bold;
}
#side-panes Underline > .underline--bar {
color: $au-bright-cyan;
}
#prompt {
dock: bottom;
border: tall $panel;
}
/* v0.6.0: focused border uses Australis bright-cyan instead of $primary
(Aurora blue) — kills the lingering blue tint the user flagged. */
#prompt:focus {
border: tall $primary;
border: tall $au-bright-cyan;
}
/* Placeholder text in the Input — dimmer than typed content. */
#prompt > .input--placeholder {
color: $au-dark-50;
}
#identity {
dock: bottom;
@@ -478,6 +586,7 @@ class RatatoskrApp(App[int]):
# without losing Input focus (INV-016).
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
]
HINT_IDLE = "Ctrl-C twice to exit"
@@ -506,17 +615,22 @@ class RatatoskrApp(App[int]):
def compose(self) -> ComposeResult:
yield Header()
# v0.5.0 layout: left column is content-only (transcript + prompt).
# Right column houses ALL telemetry — thinking-current live preview
# docked above the TabbedContent; tabs cycle Tools / Debug.
# markup=False on RichLog so labeled lines like "[cancel_failed] ..."
# render verbatim; Rich would otherwise interpret bracket spans as
# style markup. The post-Done markdown render uses Markdown() directly
# which is a Rich Renderable and renders correctly without
# widget-level markup=True.
# v0.6.0 layout: left column is content-only (transcript + streaming
# text Static + prompt). Right column hosts thinking-current live
# preview above TabbedContent cycling Tools / Debug / Thinking.
#
# The current-text Static buffers in-flight assistant tokens so
# streaming doesn't spam the RichLog with one line per delta —
# the operator sees a single growing live line, then on Done the
# Static clears and the final Markdown body lands in the transcript.
#
# markup=False on RichLog so labeled lines render verbatim; the
# post-Done Markdown() / Rule() renders are Rich Renderables and
# work without widget-level markup=True.
with Horizontal(id="main-row"):
with Vertical(id="left-column"):
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
yield Static("", id="current-text")
yield Input(id="prompt", placeholder="Type a message and press Enter")
with Vertical(id="right-column"):
yield Static("", id="thinking-current")
@@ -529,6 +643,10 @@ class RatatoskrApp(App[int]):
yield RichLog(
id="debug-log", wrap=True, markup=False, highlight=False
)
with TabPane("Thinking", id="thinking-tab"):
yield RichLog(
id="thinking-log", wrap=True, markup=False, highlight=False
)
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
# pane-name widget displays current side-pane name.
yield Static("", id="identity")
@@ -549,9 +667,48 @@ class RatatoskrApp(App[int]):
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
# placeholder text, not real telemetry. Disappear naturally as the
# log fills with real events (the placeholders scroll off the top).
from rich.text import Text as RichText
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
self.query_one("#tools-log", RichLog).write(
RichText("(no tool events yet — start a turn that uses tools)",
style=placeholder_style)
)
self.query_one("#debug-log", RichLog).write(
RichText("(waiting for worker_phase + text_boundary telemetry)",
style=placeholder_style)
)
self.query_one("#thinking-log", RichLog).write(
RichText("(no chain-of-thought captured yet — start a turn)",
style=placeholder_style)
)
self.state = "idle"
self._set_hint(self.HINT_IDLE)
def _write_turn_headers(self, turn_id: int) -> None:
"""v0.6.0: Write `── turn N ──` Rule headers across every pane so
operators can visually correlate sections during cross-pane
debugging. Called from `_stream_turn_worker` on first event of
each new turn (idempotent per turn via active_turn_id guard).
"""
from rich.rule import Rule
title = f"turn {turn_id}"
rule = Rule(title=title, style=_AU_DEMOTED)
try:
self.query_one("#transcript", RichLog).write(rule)
self.query_one("#tools-log", RichLog).write(rule)
self.query_one("#debug-log", RichLog).write(rule)
self.query_one("#thinking-log", RichLog).write(rule)
except Exception:
# Defensive: widget tree may be tearing down — never let a
# turn-header write block the SSE consumer.
pass
def _set_hint(self, hint: str) -> None:
"""Set the hint state attribute AND update the visible Static widget."""
self.hint = hint
@@ -591,20 +748,27 @@ class RatatoskrApp(App[int]):
assert content
log = self.query_one("#transcript", RichLog)
thinking_widget = self.query_one("#thinking-current", Static)
# v0.5.0: separate panes for tools vs telemetry; transcript is content only.
current_text = self.query_one("#current-text", Static)
tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-log", RichLog)
thinking_log = self.query_one("#thinking-log", RichLog)
presenter = TuiPresenterState()
try:
async for event in stream_turn(self.client, self.session_id, content):
if self.active_turn_id is None:
self.active_turn_id = event.sse_id.turn_id
# v0.6.0: turn-ID headers across all panes so the
# operator can visually correlate sections during
# cross-pane debugging.
self._write_turn_headers(self.active_turn_id)
presenter.render(
event,
log=log,
thinking_widget=thinking_widget,
current_text=current_text,
tools_log=tools_log,
debug_log=debug_log,
thinking_log=thinking_log,
raw=self.args.raw,
)
if isinstance(event, (Done, Error, Cancelled)):
@@ -672,6 +836,11 @@ class RatatoskrApp(App[int]):
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
self.query_one("#pane-name", Static).update("Debug")
def action_focus_thinking(self) -> None:
"""v0.6.0: Ctrl+3 activates the Thinking tab. INV-016 preserves Input focus."""
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
self.query_one("#pane-name", Static).update("Thinking")
def run_tui(args: ParsedArgs) -> int:
"""Sync entry point — delegates to the async resolve-then-run flow.
+221 -147
View File
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
def _spy_writes(monkeypatch) -> list:
"""Patch RichLog.write to record every arg into a list (returned)."""
"""Patch RichLog.write to record every arg into a list (returned).
Accepts *args/**kwargs so Textual's internal deferred-render path
(which calls write positionally with width/expand/shrink/scroll_end)
still works after a write-during-mount + Resize sequence.
"""
writes: list = []
original = RichLog.write
def spy(self, content, **kw):
def spy(self, content, *args, **kw):
writes.append(content)
return original(self, content, **kw)
return original(self, content, *args, **kw)
monkeypatch.setattr(RichLog, "write", spy)
return writes
@@ -127,7 +132,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
@@ -135,7 +140,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
@@ -143,59 +148,62 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
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)
assert widget.update.call_args_list[-1][0][0] == "abc"
# 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
assert log.write.call_count == 0
def test_thinking_closes_one_debuglog_entry(self) -> None:
"""thinking_closes_one_debuglog_entry [happy, v0.5.0]: 2x Thinking + WorkerPhase →
debug_log has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden;
transcript (log) untouched.
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.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
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(),
thinking_log=thinking_log,
raw=False,
)
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.5.0: closure + worker_phase write to debug_log; transcript untouched.
assert debug_log.write.call_count == 2
# 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
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
assert not log.write.called
# First write = closed thinking entry containing the full accumulated text
assert "· thinking: ab" in _text_of(debug_log.write.call_args_list[0][0][0])
# Second write = worker_phase with demotion prefix (also in debug_log)
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[1][0][0])
# Widget cleared + hidden
widget.update.assert_called_with("")
assert widget.display is False
@@ -215,12 +223,14 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars
assert last_update.startswith("")
assert len(last_update) == 201
# 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;
@@ -239,7 +249,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert widget.display is True
# Closure (WorkerPhase) → widget hidden
@@ -249,77 +259,64 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert widget.display is False
def test_multiple_thinking_runs_each_get_debuglog_entry(self) -> None:
"""multiple_thinking_runs_each_get_debuglog_entry [scenario, v0.5.0]:
Thinking → Text → Thinking → Done → TWO closed thinking entries in debug_log
(transcript receives only the Text + Done content).
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.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
current_text = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
for evt in (
Thinking(sse_id=SID, content="first"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
Thinking(sse_id=SID, content="second"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
# Close the second run with a Done.
):
state.render(
evt, log=log, thinking_widget=widget,
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,
tools_log=MagicMock(),
debug_log=debug_log,
raw=True,
log=log, thinking_widget=widget,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
)
# v0.5.0: closed thinking entries land in debug_log, NOT log.
thinking_entries = [_text_of(call[0][0]) for call in debug_log.write.call_args_list]
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
assert len(thinking_entries) == 2
assert "first" in thinking_entries[0]
assert "second" in thinking_entries[1]
# transcript receives: "hi" (Text) + "[done] ..." (terminal label) only.
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
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)]
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)}"
# 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).
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert "hi" in log_writes
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.5.0]:
widget.update raises → debug_log gets BOTH a plain-labeled fallback line
for the original Thinking event AND a `[render_error] <ExceptionClassName>`
line (NO exception message per INV-009 security clause). transcript
receives nothing — routing preservation under failure (debug-shaped
event falls back to debug_log).
"""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.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
widget = MagicMock()
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
state = TuiPresenterState()
@@ -328,18 +325,15 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
debug_log=MagicMock(),
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
writes = [c[0][0] for c in debug_log.write.call_args_list if isinstance(c[0][0], str)]
# POST-007: plain-label fallback for the original Thinking event.
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
# POST-007: render_error line with class name ONLY.
assert any(w == "[render_error] AttributeError" for w in writes), writes
# Critical: exception message MUST NOT appear in any write (INV-009 security).
assert not any("widget gone" in w for w in writes), writes
# v0.5.0 routing preservation: transcript receives NOTHING on a
# debug-shaped event's failure path.
assert not log.write.called
def test_state_reset_per_worker(self) -> None:
@@ -353,7 +347,7 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
s2 = TuiPresenterState()
assert s1.thinking_open is True
@@ -376,7 +370,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
state.render(
Cancelled(
@@ -386,18 +380,19 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# v0.5.0: closed thinking entry lands in debug_log; terminal [cancelled] in transcript.
debug_writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
# Rule start/end). terminal [cancelled] still in transcript.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("· thinking: partial") for w in debug_writes)
assert any(w.startswith("[cancelled]") for w in log_writes)
assert widget.display is False
def test_done_renders_markdown_after_label(self) -> None:
"""done_renders_markdown_after_label [happy]:
Text("hi"), Done(response="hi") with raw=False → [done] label, Rule, Markdown in RichLog.
"""done_renders_markdown_after_label [happy, v0.6.0]:
Text("hi") accumulates into current_text Static (buffered streaming);
Done(response="hi") with raw=False → [done] label + Rule + Markdown
in transcript. current_text cleared on terminal.
"""
from rich.markdown import Markdown
from rich.rule import Rule
@@ -405,6 +400,7 @@ class TestTuiPresenterState:
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
current_text = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
@@ -413,22 +409,26 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Text accumulated to current_text, NOT written to log.
current_text.update.assert_any_call("hi")
state.render(
_make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Done cleared current_text and wrote [done] label + Rule + Markdown.
current_text.update.assert_any_call("")
writes = [c[0][0] for c in log.write.call_args_list]
# Text stream wrote "hi" with no prefix.
assert "hi" in writes
# [done] label wrote.
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
assert any(_text_of(w).startswith("[done]") for w in writes)
assert any(isinstance(w, Rule) for w in writes)
assert any(isinstance(w, Markdown) for w in writes)
@@ -448,7 +448,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
state.render(
_make_tui_done(),
@@ -456,7 +456,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
writes = [c[0][0] for c in log.write.call_args_list]
assert not any(isinstance(w, Rule) for w in writes)
@@ -480,7 +480,7 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
assert not log.write.called
@@ -519,7 +519,7 @@ class TestTuiPresenterState:
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
widget.update.assert_called_with("")
@@ -543,7 +543,7 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# INV-014: write went to tools_log
assert tools_log.write.called
@@ -564,18 +564,21 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
raw=False,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called
def test_text_event_does_not_route_to_tools_log(self) -> None:
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
def test_text_event_buffers_into_current_text(self) -> None:
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
@@ -583,30 +586,35 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
assert log.write.called
assert log.write.call_args[0][0] == "hello"
# INV-015: tools_log was NOT written to
current_text.update.assert_called_once_with("hello")
assert not log.write.called
assert not tools_log.write.called
def test_text_no_prefix(self) -> None:
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
def test_text_deltas_accumulate(self) -> None:
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
concatenated content, NOT separate per-delta lines.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
line = log.write.call_args[0][0]
# Pure content, no demotion prefix.
assert line == "hello"
for tok in ("Hel", "lo", " ", "world"):
state.render(
Text(sse_id=SID, content=tok),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Final update reflects the full concatenation.
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
def test_duration_format_seconds(self) -> None:
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
@@ -620,12 +628,12 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
c[0][0]
_text_of(c[0][0])
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
if _text_of(c[0][0]).startswith("[done]")
)
assert "duration=5.5s" in done_line
assert "duration_ms=5467" not in done_line
@@ -648,12 +656,12 @@ class TestTuiPresenterState:
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
c[0][0]
_text_of(c[0][0])
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
if _text_of(c[0][0]).startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -914,6 +922,59 @@ class TestLayoutShape:
await pilot.pause()
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
async def test_done_label_styled_success(self) -> None:
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
from rich.text import Text as RichText
from textual.widgets import RichLog
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
# Probe the presenter directly — write a Done via state.render.
from ratatoskr.tui import TuiPresenterState
log = app.query_one("#transcript", RichLog)
state = TuiPresenterState()
seen: list = []
orig = log.write
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
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,
)
done = next(
c for c in seen
if isinstance(c, RichText) and _text_of(c).startswith("[done]")
)
assert done.style == "#16B866" # Aurora green
async def test_empty_state_placeholders_present(self) -> None:
"""empty_state_placeholders_present [v0.5.1]: tools-log + debug-log show
placeholder lines before any turn fires."""
from textual.widgets import RichLog
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
# Activate Debug tab so its content actually renders.
from textual.widgets import TabbedContent
tabbed = app.query_one("#side-panes", TabbedContent)
tabbed.active = "debug-tab"
await pilot.pause()
tabbed.active = "tools-tab"
await pilot.pause()
tools_log = app.query_one("#tools-log", RichLog)
debug_log = app.query_one("#debug-log", RichLog)
tools_text = " ".join(str(line) for line in tools_log.lines)
tabbed.active = "debug-tab"
await pilot.pause()
debug_text = " ".join(str(line) for line in debug_log.lines)
assert "no tool events" in tools_text
assert "worker_phase" in debug_text
async def test_pane_name_updates_on_tab_switch(self) -> None:
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
@@ -1100,7 +1161,10 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
class TestStreamTurnWorker:
@respx.mock
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_renders_markdown [happy,tracer]: …"""
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
Text deltas go to current_text (not transcript); on Done, transcript
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
"42:2", _DONE_BODY
)
@@ -1115,20 +1179,25 @@ class TestStreamTurnWorker:
await pilot.pause()
await _submit_and_wait(app, pilot, "hi")
assert app.state == "idle"
# Streamed delta + done label + rule + markdown render
assert any(w == "hello" for w in writes)
assert any("[done]" in str(w) for w in writes)
# The post-Done markdown render uses rich Rule + Markdown — non-string writes.
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
from rich.markdown import Markdown
from rich.rule import Rule
assert not any(w == "hello" for w in writes)
assert any("[done]" in str(w) for w in writes)
# Post-Done: Markdown body + Rule + turn-header Rule all present.
assert any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""raw_flag_skips_markdown_render [trace]: …"""
"""raw_flag_skips_markdown_render [trace, v0.6.0]:
With --raw, no Markdown render. A turn-header Rule IS still written
(v0.6.0 INV — turn correlation lives in every pane). The post-Done
Rule(separator) is suppressed; accumulated streamed text is written
as a plain string instead.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
"42:2", _DONE_BODY
)
@@ -1140,12 +1209,17 @@ class TestStreamTurnWorker:
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
from rich.markdown import Markdown
from rich.rule import Rule
# No Markdown in raw mode.
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
# Only turn-header Rules — one per pane (transcript + tools +
# debug + thinking = 4). No post-Done separator Rule.
rules = [w for w in writes if isinstance(w, Rule)]
assert len(rules) == 4, f"expected 4 turn-header Rules, got {len(rules)}"
# Accumulated text "hi" written as plain string post-Done.
assert "hi" in writes
@respx.mock
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.5.0"
version = "0.6.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },