Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac690c11d5 | |||
| d845b20efd | |||
| 8463eb22ff | |||
| eb93e6d5f0 | |||
| cfee89ac1c |
@@ -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-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-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-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-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** *(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-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** *(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.
|
||||||
|
|
||||||
## TESTS (additions / changes to test_tui.py)
|
## 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
|
## Current state / in-flight
|
||||||
|
|
||||||
_As of 2026-05-24 (post-v0.5.1 UI polish pass):_
|
_As of 2026-05-24 (post-v0.6.3 Australis restored, $background → black):_
|
||||||
|
|
||||||
**Status: v0.5.1 shipped.** Nine core issues complete (`sse_client`
|
**Status: v0.6.3 shipped.** Nine core issues complete (`sse_client`
|
||||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||||
startup error visibility #6, presenter contract semantics amendment
|
startup error visibility #6, presenter contract semantics amendment
|
||||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||||
@@ -51,7 +51,11 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
|
|||||||
land). CLI mode (--send) unaffected by design — INV-018.
|
land). CLI mode (--send) unaffected by design — INV-018.
|
||||||
|
|
||||||
Last commits on `main`:
|
Last commits on `main`:
|
||||||
- v0.5.1 style(tui): polish pass — colored terminal labels, placeholders, padding
|
- 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)
|
||||||
|
- `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)
|
||||||
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
|
- `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)
|
- `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)
|
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.5.1"
|
version = "0.6.3"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+234
-55
@@ -22,7 +22,6 @@ from textual.widgets import (
|
|||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
|
||||||
ListItem,
|
ListItem,
|
||||||
ListView,
|
ListView,
|
||||||
RichLog,
|
RichLog,
|
||||||
@@ -69,16 +68,25 @@ from ratatoskr.sse_client import (
|
|||||||
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
|
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
|
||||||
# terminal-event labels (error/cancelled).
|
# terminal-event labels (error/cancelled).
|
||||||
#
|
#
|
||||||
|
# **v0.6.3 single deviation from spec**: `$background` is `#000000` (pure
|
||||||
|
# black), NOT Australis Ice black `#222531`. The Ice black is RGB(34,37,49)
|
||||||
|
# — blue dominant — and at App-wide scale the cumulative cast reads as
|
||||||
|
# "the whole app is blue" to operators (even though no single surface is
|
||||||
|
# "blue" in the strict-color sense). Pure black for the App background
|
||||||
|
# kills that perception. EVERY OTHER Australis value — Aurora accents,
|
||||||
|
# Sea darks for chrome (surface/panel), Ice white foreground, Dawn
|
||||||
|
# accents — stays verbatim per spec.
|
||||||
|
#
|
||||||
# Mapping to Textual's Theme semantic tokens:
|
# Mapping to Textual's Theme semantic tokens:
|
||||||
# primary = Aurora blue (#6388D8) — focus rings, active selection.
|
# primary = Aurora blue (#6388D8) — focus rings, active selection.
|
||||||
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
|
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
|
||||||
# accent = Aurora bright cyan (#42dcd1) — bright accents (pane-name, prompt echo).
|
# accent = Aurora bright cyan (#42dcd1) — bright accents.
|
||||||
# success = Aurora green (#16B866) — [done] label.
|
# success = Aurora green (#16B866) — [done] label.
|
||||||
# warning = Dawn yellow (#e1c631) — [cancelled] label.
|
# warning = Dawn yellow (#e1c631) — [cancelled] label.
|
||||||
# error = Dawn red (#ff491a) — [error] label.
|
# error = Dawn red (#ff491a) — [error] label.
|
||||||
# foreground = Ice white (#a9bcc3) — default text.
|
# foreground = Ice white (#a9bcc3) — default text.
|
||||||
# background = Ice black (#222531) — App background.
|
# background = pure black (#000000) — App background (v0.6.3 deviation).
|
||||||
# surface = Sea bright black (#373b46) — raised chrome (header/footer/input).
|
# surface = Sea bright black (#373b46) — raised chrome.
|
||||||
# panel = Sea dark 30 (#414751) — borders, separators.
|
# panel = Sea dark 30 (#414751) — borders, separators.
|
||||||
|
|
||||||
AUSTRALIS_THEME = Theme(
|
AUSTRALIS_THEME = Theme(
|
||||||
@@ -90,7 +98,7 @@ AUSTRALIS_THEME = Theme(
|
|||||||
warning="#e1c631",
|
warning="#e1c631",
|
||||||
error="#ff491a",
|
error="#ff491a",
|
||||||
foreground="#a9bcc3",
|
foreground="#a9bcc3",
|
||||||
background="#222531",
|
background="#000000",
|
||||||
surface="#373b46",
|
surface="#373b46",
|
||||||
panel="#414751",
|
panel="#414751",
|
||||||
dark=True,
|
dark=True,
|
||||||
@@ -116,9 +124,9 @@ AUSTRALIS_THEME = Theme(
|
|||||||
_AU_SUCCESS = "#16B866"
|
_AU_SUCCESS = "#16B866"
|
||||||
_AU_ERROR = "#ff491a"
|
_AU_ERROR = "#ff491a"
|
||||||
_AU_WARNING = "#e1c631"
|
_AU_WARNING = "#e1c631"
|
||||||
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
|
_AU_USER_ECHO = "#42dcd1" # Aurora bright cyan — operator's voice
|
||||||
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
|
_AU_DEMOTED = "#86929d" # Sea dark 60 — demoted telemetry
|
||||||
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
|
_AU_DEMOTED_FAINT = "#6e7882" # Sea dark 50 — empty-state placeholders
|
||||||
|
|
||||||
|
|
||||||
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
||||||
@@ -179,6 +187,11 @@ class TuiPresenterState:
|
|||||||
|
|
||||||
thinking_buffer: list[str] = field(default_factory=list)
|
thinking_buffer: list[str] = field(default_factory=list)
|
||||||
thinking_open: bool = False
|
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(
|
def render(
|
||||||
self,
|
self,
|
||||||
@@ -186,20 +199,26 @@ class TuiPresenterState:
|
|||||||
*,
|
*,
|
||||||
log: RichLog,
|
log: RichLog,
|
||||||
thinking_widget: Static,
|
thinking_widget: Static,
|
||||||
|
current_text: Static,
|
||||||
tools_log: RichLog,
|
tools_log: RichLog,
|
||||||
debug_log: RichLog,
|
debug_log: RichLog,
|
||||||
|
thinking_log: RichLog,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||||
|
|
||||||
v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text,
|
v0.6.0 routing:
|
||||||
terminal labels ([done] / [error] / [cancelled]), and the
|
- `log` (transcript) = content only: user-prompt echo (written
|
||||||
post-Done Markdown render. All telemetry (Thinking closed runs,
|
outside the presenter), terminal labels, post-Done Markdown body.
|
||||||
WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all
|
- `current_text` (Static below transcript) = live-streaming Text
|
||||||
tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools
|
deltas accumulated into one growing line; cleared on terminal.
|
||||||
tab). Live thinking deltas continue to update `thinking_widget`.
|
- `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(
|
assert isinstance(
|
||||||
event,
|
event,
|
||||||
@@ -236,25 +255,48 @@ class TuiPresenterState:
|
|||||||
thinking_widget.update(f"thinking… {tail}")
|
thinking_widget.update(f"thinking… {tail}")
|
||||||
return
|
return
|
||||||
# Non-thinking event: close any open thinking run.
|
# Non-thinking event: close any open thinking run.
|
||||||
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
|
# v0.6.0: closed thinking runs route to thinking_log (Thinking
|
||||||
# transcript — keeps the main pane content-only.
|
# 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:
|
if self.thinking_open:
|
||||||
full_thinking = "".join(self.thinking_buffer)
|
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_buffer.clear()
|
||||||
self.thinking_open = False
|
self.thinking_open = False
|
||||||
thinking_widget.update("")
|
thinking_widget.update("")
|
||||||
thinking_widget.display = False
|
thinking_widget.display = False
|
||||||
# Now render the non-thinking event itself.
|
# Now render the non-thinking event itself.
|
||||||
if isinstance(event, Text):
|
if isinstance(event, Text):
|
||||||
# Streamed text content — no prefix, no demotion.
|
# v0.6.0: streaming text accumulates into current_text Static
|
||||||
log.write(event.content)
|
# — one growing live line, NOT per-delta RichLog entries.
|
||||||
|
self.text_buffer.append(event.content)
|
||||||
|
current_text.update("".join(self.text_buffer))
|
||||||
return
|
return
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
# Terminal events: load-bearing label tinted per outcome.
|
# Terminal event: clear the streaming Static first so the
|
||||||
# v0.5.1 polish: Aurora green / Dawn red / Dawn yellow so the
|
# live-preview band collapses. Then write the colored label
|
||||||
# turn-terminal status is scannable at a glance vs blending
|
# + (non-raw) Markdown body / (raw) accumulated plain text
|
||||||
# with default foreground.
|
# 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):
|
if isinstance(event, Done):
|
||||||
log.write(RichText(
|
log.write(RichText(
|
||||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
||||||
@@ -262,13 +304,15 @@ class TuiPresenterState:
|
|||||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||||
style=_AU_SUCCESS,
|
style=_AU_SUCCESS,
|
||||||
))
|
))
|
||||||
if not raw:
|
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.markdown import Markdown
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
|
|
||||||
# Rule tinted to match the column border so the
|
|
||||||
# streamed-text / markdown-render boundary reads as
|
|
||||||
# part of the chrome family, not a content artifact.
|
|
||||||
log.write(Rule(style=_AU_DEMOTED))
|
log.write(Rule(style=_AU_DEMOTED))
|
||||||
log.write(Markdown(event.response))
|
log.write(Markdown(event.response))
|
||||||
elif isinstance(event, Error):
|
elif isinstance(event, Error):
|
||||||
@@ -318,14 +362,17 @@ class TuiPresenterState:
|
|||||||
# the original event AND a render_error line with the class name only
|
# the original event AND a render_error line with the class name only
|
||||||
# (NO exception message — security clause). Volva F1 fix.
|
# (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:
|
# to the same destination the successful render would have used:
|
||||||
# - ToolStart/ToolResult → tools_log
|
# - ToolStart/ToolResult → tools_log
|
||||||
# - WorkerPhase/Thinking/TextBoundary → debug_log
|
# - Thinking → thinking_log
|
||||||
# - everything else (Text/Done/Error/Cancelled) → log
|
# - WorkerPhase/TextBoundary → debug_log
|
||||||
|
# - everything else → log
|
||||||
if isinstance(event, (ToolStart, ToolResult)):
|
if isinstance(event, (ToolStart, ToolResult)):
|
||||||
target = tools_log
|
target = tools_log
|
||||||
elif isinstance(event, (WorkerPhase, Thinking, TextBoundary)):
|
elif isinstance(event, Thinking):
|
||||||
|
target = thinking_log
|
||||||
|
elif isinstance(event, (WorkerPhase, TextBoundary)):
|
||||||
target = debug_log
|
target = debug_log
|
||||||
else:
|
else:
|
||||||
target = log
|
target = log
|
||||||
@@ -344,6 +391,28 @@ class AgentPickerApp(App[str | None]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
|
/* v0.6.1: kill Textual's $primary-blue tints everywhere — Header sub-
|
||||||
|
widgets (HeaderIcon etc.) have their own $primary tinting that the
|
||||||
|
parent `Header { background: $surface }` rule alone doesn't cover.
|
||||||
|
Sub-selectors force the cool palette down to every level. */
|
||||||
|
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||||
|
background: $surface;
|
||||||
|
color: $au-bright-blue;
|
||||||
|
}
|
||||||
|
Footer {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
/* v0.6.1: scrollbar uses Textual's $primary-tint by default. Force
|
||||||
|
Australis Sea darks so the scrollbar gutter doesn't read as a blue
|
||||||
|
strip. Applied to ListView (the scrollable widget here). */
|
||||||
|
ListView {
|
||||||
|
scrollbar-background: $background;
|
||||||
|
scrollbar-background-hover: $background;
|
||||||
|
scrollbar-background-active: $background;
|
||||||
|
scrollbar-color: $au-dark-50;
|
||||||
|
scrollbar-color-hover: $au-dark-60;
|
||||||
|
scrollbar-color-active: $au-bright-cyan;
|
||||||
|
}
|
||||||
#picker-prompt {
|
#picker-prompt {
|
||||||
dock: top;
|
dock: top;
|
||||||
height: 1;
|
height: 1;
|
||||||
@@ -355,9 +424,29 @@ class AgentPickerApp(App[str | None]):
|
|||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: $background;
|
background: $background;
|
||||||
}
|
}
|
||||||
#agent-list > ListItem.--highlight {
|
/* Multi-line agent items. Each ListItem is auto-height so the full
|
||||||
background: $primary;
|
description wraps below the agent_id/name line — no truncation. */
|
||||||
color: $au-bright-white;
|
#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;
|
||||||
|
}
|
||||||
|
/* Children of highlighted items keep their colors — the dark-30 bg
|
||||||
|
provides enough contrast for bright-blue id + dark-60 desc text. */
|
||||||
|
.agent-id-line {
|
||||||
|
color: $au-bright-blue;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
.agent-desc {
|
||||||
|
color: $au-bright-70;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -380,9 +469,16 @@ class AgentPickerApp(App[str | None]):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
yield Static("Pick an agent for the new session:", id="picker-prompt")
|
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(
|
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
|
for a in self.agents
|
||||||
],
|
],
|
||||||
id="agent-list",
|
id="agent-list",
|
||||||
@@ -425,13 +521,27 @@ class RatatoskrApp(App[int]):
|
|||||||
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
|
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
|
||||||
# etc.) carry colors so a future theme swap rebinds centrally.
|
# etc.) carry colors so a future theme swap rebinds centrally.
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
Header {
|
/* v0.6.1: kill Textual's default $primary-blue tinting on chrome —
|
||||||
|
Header sub-widgets (HeaderIcon, HeaderTitle, HeaderClock) each carry
|
||||||
|
their own $primary tint that the parent `Header { background }` rule
|
||||||
|
doesn't override; sub-selectors force the cool palette down. */
|
||||||
|
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||||
background: $surface;
|
background: $surface;
|
||||||
color: $au-bright-blue;
|
color: $au-bright-blue;
|
||||||
}
|
}
|
||||||
Footer {
|
Footer {
|
||||||
background: $surface;
|
background: $surface;
|
||||||
}
|
}
|
||||||
|
/* v0.6.1: scrollbars default to $primary-tint blue. Force Sea darks
|
||||||
|
on the scrollable widgets (RichLog instances). */
|
||||||
|
RichLog {
|
||||||
|
scrollbar-background: $background;
|
||||||
|
scrollbar-background-hover: $background;
|
||||||
|
scrollbar-background-active: $background;
|
||||||
|
scrollbar-color: $au-dark-50;
|
||||||
|
scrollbar-color-hover: $au-dark-60;
|
||||||
|
scrollbar-color-active: $au-bright-cyan;
|
||||||
|
}
|
||||||
#main-row {
|
#main-row {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
@@ -442,37 +552,56 @@ class RatatoskrApp(App[int]):
|
|||||||
#right-column {
|
#right-column {
|
||||||
width: 1fr;
|
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 {
|
#thinking-current {
|
||||||
dock: top;
|
dock: bottom;
|
||||||
height: auto;
|
height: auto;
|
||||||
color: $au-dark-60;
|
color: $au-dark-60;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
text-style: italic;
|
text-style: italic;
|
||||||
|
background: $background;
|
||||||
}
|
}
|
||||||
#transcript {
|
#transcript {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: $background;
|
background: $background;
|
||||||
padding: 0 1;
|
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;
|
background: $background;
|
||||||
padding: 0 1;
|
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;
|
background: $surface;
|
||||||
}
|
}
|
||||||
/* Active tab: Aurora bright-cyan label so the operator's eye lands
|
#side-panes ContentTab.-active {
|
||||||
on the currently selected pane name. */
|
|
||||||
#side-panes Tab.-active {
|
|
||||||
color: $au-bright-cyan;
|
color: $au-bright-cyan;
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
#side-panes Underline > .underline--bar {
|
||||||
|
color: $au-bright-cyan;
|
||||||
|
}
|
||||||
#prompt {
|
#prompt {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
border: tall $panel;
|
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 {
|
#prompt:focus {
|
||||||
border: tall $primary;
|
border: tall $au-bright-cyan;
|
||||||
}
|
}
|
||||||
/* Placeholder text in the Input — dimmer than typed content. */
|
/* Placeholder text in the Input — dimmer than typed content. */
|
||||||
#prompt > .input--placeholder {
|
#prompt > .input--placeholder {
|
||||||
@@ -505,6 +634,7 @@ class RatatoskrApp(App[int]):
|
|||||||
# without losing Input focus (INV-016).
|
# without losing Input focus (INV-016).
|
||||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
||||||
Binding("ctrl+2", "focus_debug", "Debug 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"
|
HINT_IDLE = "Ctrl-C twice to exit"
|
||||||
@@ -533,20 +663,24 @@ class RatatoskrApp(App[int]):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
# v0.5.0 layout: left column is content-only (transcript + prompt).
|
# v0.6.0 layout: left column is content-only (transcript + streaming
|
||||||
# Right column houses ALL telemetry — thinking-current live preview
|
# text Static + prompt). Right column hosts thinking-current live
|
||||||
# docked above the TabbedContent; tabs cycle Tools / Debug.
|
# preview above TabbedContent cycling Tools / Debug / Thinking.
|
||||||
# markup=False on RichLog so labeled lines like "[cancel_failed] ..."
|
#
|
||||||
# render verbatim; Rich would otherwise interpret bracket spans as
|
# The current-text Static buffers in-flight assistant tokens so
|
||||||
# style markup. The post-Done markdown render uses Markdown() directly
|
# streaming doesn't spam the RichLog with one line per delta —
|
||||||
# which is a Rich Renderable and renders correctly without
|
# the operator sees a single growing live line, then on Done the
|
||||||
# widget-level markup=True.
|
# 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 Horizontal(id="main-row"):
|
||||||
with Vertical(id="left-column"):
|
with Vertical(id="left-column"):
|
||||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
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")
|
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||||
with Vertical(id="right-column"):
|
with Vertical(id="right-column"):
|
||||||
yield Static("", id="thinking-current")
|
|
||||||
with TabbedContent(id="side-panes"):
|
with TabbedContent(id="side-panes"):
|
||||||
with TabPane("Tools", id="tools-tab"):
|
with TabPane("Tools", id="tools-tab"):
|
||||||
yield RichLog(
|
yield RichLog(
|
||||||
@@ -556,6 +690,15 @@ class RatatoskrApp(App[int]):
|
|||||||
yield RichLog(
|
yield RichLog(
|
||||||
id="debug-log", wrap=True, markup=False, highlight=False
|
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
|
||||||
|
)
|
||||||
|
# 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).
|
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||||
# pane-name widget displays current side-pane name.
|
# pane-name widget displays current side-pane name.
|
||||||
yield Static("", id="identity")
|
yield Static("", id="identity")
|
||||||
@@ -588,12 +731,36 @@ class RatatoskrApp(App[int]):
|
|||||||
style=placeholder_style)
|
style=placeholder_style)
|
||||||
)
|
)
|
||||||
self.query_one("#debug-log", RichLog).write(
|
self.query_one("#debug-log", RichLog).write(
|
||||||
RichText("(waiting for telemetry — start a turn)",
|
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)
|
style=placeholder_style)
|
||||||
)
|
)
|
||||||
self.state = "idle"
|
self.state = "idle"
|
||||||
self._set_hint(self.HINT_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:
|
def _set_hint(self, hint: str) -> None:
|
||||||
"""Set the hint state attribute AND update the visible Static widget."""
|
"""Set the hint state attribute AND update the visible Static widget."""
|
||||||
self.hint = hint
|
self.hint = hint
|
||||||
@@ -633,20 +800,27 @@ class RatatoskrApp(App[int]):
|
|||||||
assert content
|
assert content
|
||||||
log = self.query_one("#transcript", RichLog)
|
log = self.query_one("#transcript", RichLog)
|
||||||
thinking_widget = self.query_one("#thinking-current", Static)
|
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)
|
tools_log = self.query_one("#tools-log", RichLog)
|
||||||
debug_log = self.query_one("#debug-log", RichLog)
|
debug_log = self.query_one("#debug-log", RichLog)
|
||||||
|
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||||
presenter = TuiPresenterState()
|
presenter = TuiPresenterState()
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn(self.client, self.session_id, content):
|
async for event in stream_turn(self.client, self.session_id, content):
|
||||||
if self.active_turn_id is None:
|
if self.active_turn_id is None:
|
||||||
self.active_turn_id = event.sse_id.turn_id
|
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(
|
presenter.render(
|
||||||
event,
|
event,
|
||||||
log=log,
|
log=log,
|
||||||
thinking_widget=thinking_widget,
|
thinking_widget=thinking_widget,
|
||||||
|
current_text=current_text,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
|
thinking_log=thinking_log,
|
||||||
raw=self.args.raw,
|
raw=self.args.raw,
|
||||||
)
|
)
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
@@ -714,6 +888,11 @@ class RatatoskrApp(App[int]):
|
|||||||
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
|
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
|
||||||
self.query_one("#pane-name", Static).update("Debug")
|
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:
|
def run_tui(args: ParsedArgs) -> int:
|
||||||
"""Sync entry point — delegates to the async resolve-then-run flow.
|
"""Sync entry point — delegates to the async resolve-then-run flow.
|
||||||
|
|||||||
+149
-136
@@ -132,7 +132,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content="b"),
|
Thinking(sse_id=SID, content="b"),
|
||||||
@@ -140,7 +140,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content="c"),
|
Thinking(sse_id=SID, content="c"),
|
||||||
@@ -148,7 +148,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_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
|
# Widget updated 3 times — once per delta — with cumulative content
|
||||||
assert widget.update.call_count == 3
|
assert widget.update.call_count == 3
|
||||||
@@ -160,48 +160,50 @@ class TestTuiPresenterState:
|
|||||||
# No RichLog write yet — closure hasn't fired
|
# No RichLog write yet — closure hasn't fired
|
||||||
assert log.write.call_count == 0
|
assert log.write.call_count == 0
|
||||||
|
|
||||||
def test_thinking_closes_one_debuglog_entry(self) -> None:
|
def test_thinking_closes_to_thinking_log(self) -> None:
|
||||||
"""thinking_closes_one_debuglog_entry [happy, v0.5.0]: 2x Thinking + WorkerPhase →
|
"""thinking_closes_to_thinking_log [happy, v0.6.0]: 2x Thinking + WorkerPhase →
|
||||||
debug_log has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden;
|
thinking_log gets Rule(start) + Markdown + Rule(end); debug_log gets worker_phase;
|
||||||
transcript (log) untouched.
|
transcript and tools_log untouched. Widget cleared+hidden.
|
||||||
"""
|
"""
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.rule import Rule
|
||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
debug_log = MagicMock()
|
debug_log = MagicMock()
|
||||||
|
thinking_log = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
for content in ("a", "b"):
|
||||||
Thinking(sse_id=SID, content="a"),
|
state.render(
|
||||||
log=log,
|
Thinking(sse_id=SID, content=content),
|
||||||
thinking_widget=widget,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
thinking_widget=widget,
|
||||||
debug_log=debug_log,
|
tools_log=MagicMock(),
|
||||||
raw=False,
|
debug_log=debug_log,
|
||||||
)
|
current_text=MagicMock(),
|
||||||
state.render(
|
thinking_log=thinking_log,
|
||||||
Thinking(sse_id=SID, content="b"),
|
raw=False,
|
||||||
log=log,
|
)
|
||||||
thinking_widget=widget,
|
|
||||||
tools_log=MagicMock(),
|
|
||||||
debug_log=debug_log,
|
|
||||||
raw=False,
|
|
||||||
)
|
|
||||||
state.render(
|
state.render(
|
||||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
|
current_text=MagicMock(),
|
||||||
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# v0.5.0: closure + worker_phase write to debug_log; transcript untouched.
|
# v0.6.0: closure writes Rule(start) + Markdown + Rule(end) to thinking_log.
|
||||||
assert debug_log.write.call_count == 2
|
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
|
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 cleared + hidden
|
||||||
widget.update.assert_called_with("")
|
widget.update.assert_called_with("")
|
||||||
assert widget.display is False
|
assert widget.display is False
|
||||||
@@ -221,7 +223,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_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]
|
last_update = widget.update.call_args_list[-1][0][0]
|
||||||
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||||||
@@ -247,7 +249,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
assert widget.display is True
|
assert widget.display is True
|
||||||
# Closure (WorkerPhase) → widget hidden
|
# Closure (WorkerPhase) → widget hidden
|
||||||
@@ -257,77 +259,64 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
assert widget.display is False
|
assert widget.display is False
|
||||||
|
|
||||||
def test_multiple_thinking_runs_each_get_debuglog_entry(self) -> None:
|
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||||||
"""multiple_thinking_runs_each_get_debuglog_entry [scenario, v0.5.0]:
|
"""multiple_thinking_runs_each_get_thinking_log_section [scenario, v0.6.0]:
|
||||||
Thinking → Text → Thinking → Done → TWO closed thinking entries in debug_log
|
Thinking → Text → Thinking → Done → TWO start/end Rule + Markdown sections
|
||||||
(transcript receives only the Text + Done content).
|
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
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
debug_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
|
current_text = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
for evt in (
|
||||||
Thinking(sse_id=SID, content="first"),
|
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"),
|
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"),
|
Thinking(sse_id=SID, content="second"),
|
||||||
log=log,
|
):
|
||||||
thinking_widget=widget,
|
state.render(
|
||||||
tools_log=MagicMock(),
|
evt, log=log, thinking_widget=widget,
|
||||||
debug_log=debug_log,
|
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||||
)
|
)
|
||||||
# Close the second run with a Done.
|
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
log=log, thinking_widget=widget,
|
||||||
thinking_widget=widget,
|
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||||
debug_log=debug_log,
|
|
||||||
raw=True,
|
|
||||||
)
|
)
|
||||||
# v0.5.0: closed thinking entries land in debug_log, NOT log.
|
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
|
||||||
thinking_entries = [_text_of(call[0][0]) for call in debug_log.write.call_args_list]
|
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||||
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
|
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||||||
assert len(thinking_entries) == 2
|
markdowns = [w for w in thinking_writes if isinstance(w, Markdown)]
|
||||||
assert "first" in thinking_entries[0]
|
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||||||
assert "second" in thinking_entries[1]
|
assert len(markdowns) == 2, f"expected 2 Markdown sections, got {len(markdowns)}"
|
||||||
# transcript receives: "hi" (Text) + "[done] ..." (terminal label) only.
|
# 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]
|
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))
|
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||||
|
|
||||||
def test_render_exception_fallback(self) -> None:
|
def test_render_exception_fallback(self) -> None:
|
||||||
"""render_exception_fallback [adversarial, v0.5.0]:
|
"""render_exception_fallback [adversarial, v0.6.0]:
|
||||||
widget.update raises → debug_log gets BOTH a plain-labeled fallback line
|
widget.update raises → thinking_log gets the plain-label fallback for
|
||||||
for the original Thinking event AND a `[render_error] <ExceptionClassName>`
|
Thinking (per v0.6.0 routing — Thinking now routes to thinking_log,
|
||||||
line (NO exception message per INV-009 security clause). transcript
|
not debug_log). render_error line follows. transcript untouched.
|
||||||
receives nothing — routing preservation under failure (debug-shaped
|
|
||||||
event falls back to debug_log).
|
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
debug_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
@@ -336,18 +325,15 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=MagicMock(),
|
||||||
|
current_text=MagicMock(),
|
||||||
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
writes = [c[0][0] for c in debug_log.write.call_args_list if isinstance(c[0][0], str)]
|
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
|
||||||
# POST-007: plain-label fallback for the original Thinking event.
|
|
||||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
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
|
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
|
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
|
assert not log.write.called
|
||||||
|
|
||||||
def test_state_reset_per_worker(self) -> None:
|
def test_state_reset_per_worker(self) -> None:
|
||||||
@@ -361,7 +347,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
s2 = TuiPresenterState()
|
s2 = TuiPresenterState()
|
||||||
assert s1.thinking_open is True
|
assert s1.thinking_open is True
|
||||||
@@ -384,7 +370,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
Cancelled(
|
Cancelled(
|
||||||
@@ -394,18 +380,19 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
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.
|
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
|
||||||
debug_writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
|
# Rule start/end). terminal [cancelled] still in transcript.
|
||||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
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 any(w.startswith("[cancelled]") for w in log_writes)
|
||||||
assert widget.display is False
|
assert widget.display is False
|
||||||
|
|
||||||
def test_done_renders_markdown_after_label(self) -> None:
|
def test_done_renders_markdown_after_label(self) -> None:
|
||||||
"""done_renders_markdown_after_label [happy]:
|
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
||||||
Text("hi"), Done(response="hi") with raw=False → [done] label, Rule, Markdown in RichLog.
|
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.markdown import Markdown
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
@@ -413,6 +400,7 @@ class TestTuiPresenterState:
|
|||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
|
current_text = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
@@ -421,22 +409,26 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
|
current_text=current_text,
|
||||||
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
|
# Text accumulated to current_text, NOT written to log.
|
||||||
|
current_text.update.assert_any_call("hi")
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
|
current_text=current_text,
|
||||||
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
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]
|
writes = [c[0][0] for c in log.write.call_args_list]
|
||||||
# Text stream wrote "hi" with no prefix.
|
|
||||||
assert "hi" in writes
|
|
||||||
# v0.5.1: [done] label is now RichText (Aurora green); plain content test via _text_of.
|
|
||||||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||||||
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
|
|
||||||
assert any(isinstance(w, Rule) for w in writes)
|
assert any(isinstance(w, Rule) for w in writes)
|
||||||
assert any(isinstance(w, Markdown) for w in writes)
|
assert any(isinstance(w, Markdown) for w in writes)
|
||||||
|
|
||||||
@@ -456,7 +448,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=True,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
@@ -464,7 +456,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_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]
|
writes = [c[0][0] for c in log.write.call_args_list]
|
||||||
assert not any(isinstance(w, Rule) for w in writes)
|
assert not any(isinstance(w, Rule) for w in writes)
|
||||||
@@ -488,7 +480,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||||||
assert not log.write.called
|
assert not log.write.called
|
||||||
@@ -527,7 +519,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_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.
|
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
|
||||||
widget.update.assert_called_with("")
|
widget.update.assert_called_with("")
|
||||||
@@ -551,7 +543,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
# INV-014: write went to tools_log
|
# INV-014: write went to tools_log
|
||||||
assert tools_log.write.called
|
assert tools_log.write.called
|
||||||
@@ -572,18 +564,21 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
assert tools_log.write.called
|
assert tools_log.write.called
|
||||||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||||||
assert not log.write.called
|
assert not log.write.called
|
||||||
|
|
||||||
def test_text_event_does_not_route_to_tools_log(self) -> None:
|
def test_text_event_buffers_into_current_text(self) -> None:
|
||||||
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
|
"""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
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
tools_log = MagicMock()
|
tools_log = MagicMock()
|
||||||
|
current_text = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content="hello"),
|
Text(sse_id=SID, content="hello"),
|
||||||
@@ -591,30 +586,35 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
|
current_text=current_text,
|
||||||
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
assert log.write.called
|
current_text.update.assert_called_once_with("hello")
|
||||||
assert log.write.call_args[0][0] == "hello"
|
assert not log.write.called
|
||||||
# INV-015: tools_log was NOT written to
|
|
||||||
assert not tools_log.write.called
|
assert not tools_log.write.called
|
||||||
|
|
||||||
def test_text_no_prefix(self) -> None:
|
def test_text_deltas_accumulate(self) -> None:
|
||||||
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
|
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
|
||||||
|
concatenated content, NOT separate per-delta lines.
|
||||||
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
current_text = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
for tok in ("Hel", "lo", " ", "world"):
|
||||||
Text(sse_id=SID, content="hello"),
|
state.render(
|
||||||
log=log,
|
Text(sse_id=SID, content=tok),
|
||||||
thinking_widget=MagicMock(),
|
log=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
raw=False,
|
debug_log=MagicMock(),
|
||||||
)
|
current_text=current_text,
|
||||||
line = log.write.call_args[0][0]
|
thinking_log=MagicMock(),
|
||||||
# Pure content, no demotion prefix.
|
raw=False,
|
||||||
assert line == "hello"
|
)
|
||||||
|
# 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:
|
def test_duration_format_seconds(self) -> None:
|
||||||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||||||
@@ -628,7 +628,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=True,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
_text_of(c[0][0])
|
_text_of(c[0][0])
|
||||||
@@ -656,7 +656,7 @@ class TestTuiPresenterState:
|
|||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
raw=True,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
_text_of(c[0][0])
|
_text_of(c[0][0])
|
||||||
@@ -943,7 +943,7 @@ class TestLayoutShape:
|
|||||||
thinking_widget=app.query_one("#thinking-current"),
|
thinking_widget=app.query_one("#thinking-current"),
|
||||||
tools_log=app.query_one("#tools-log", RichLog),
|
tools_log=app.query_one("#tools-log", RichLog),
|
||||||
debug_log=app.query_one("#debug-log", RichLog),
|
debug_log=app.query_one("#debug-log", RichLog),
|
||||||
raw=True,
|
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
done = next(
|
done = next(
|
||||||
c for c in seen
|
c for c in seen
|
||||||
@@ -973,7 +973,7 @@ class TestLayoutShape:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
debug_text = " ".join(str(line) for line in debug_log.lines)
|
debug_text = " ".join(str(line) for line in debug_log.lines)
|
||||||
assert "no tool events" in tools_text
|
assert "no tool events" in tools_text
|
||||||
assert "waiting for telemetry" in debug_text
|
assert "worker_phase" in debug_text
|
||||||
|
|
||||||
async def test_pane_name_updates_on_tab_switch(self) -> None:
|
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.
|
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
|
||||||
@@ -1161,7 +1161,10 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
|||||||
class TestStreamTurnWorker:
|
class TestStreamTurnWorker:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
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(
|
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||||||
"42:2", _DONE_BODY
|
"42:2", _DONE_BODY
|
||||||
)
|
)
|
||||||
@@ -1176,20 +1179,25 @@ class TestStreamTurnWorker:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await _submit_and_wait(app, pilot, "hi")
|
await _submit_and_wait(app, pilot, "hi")
|
||||||
assert app.state == "idle"
|
assert app.state == "idle"
|
||||||
# Streamed delta + done label + rule + markdown render
|
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
|
||||||
assert any(w == "hello" for w in writes)
|
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
|
||||||
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.
|
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.rule import Rule
|
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, Markdown) for w in writes)
|
||||||
assert any(isinstance(w, Rule) for w in writes)
|
assert any(isinstance(w, Rule) for w in writes)
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
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(
|
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||||||
"42:2", _DONE_BODY
|
"42:2", _DONE_BODY
|
||||||
)
|
)
|
||||||
@@ -1201,12 +1209,17 @@ class TestStreamTurnWorker:
|
|||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await _submit_and_wait(app, pilot, "x")
|
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.markdown import Markdown
|
||||||
from rich.rule import Rule
|
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, 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
|
@respx.mock
|
||||||
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user