Compare commits

..

3 Commits

Author SHA1 Message Date
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
vh ffd22fb587 refactor(tui): content-only main pane + Debug tab + dark chrome (v0.5.0)
Two operator-driven changes off v0.4.1:

1. **Main pane is content-only.** Pre-v0.5.0 the transcript mixed
   assistant text with telemetry (Thinking closed runs, WorkerPhase,
   TextBoundary) — only tool events were factored out per #13. The
   transcript now receives ONLY: user-prompt echo, assistant Text
   deltas, [done]/[error]/[cancelled] terminal labels, and the
   post-Done Markdown render. All telemetry routes to a new Debug
   tab in the right column.

2. **Chrome no longer blue.** Textual's default Header / Footer /
   active-tab styling tints with `$primary` (Aurora blue under
   Australis), which read as garish on dark terminals. Header,
   Footer, and the TabbedContent tab strip get explicit
   `background: $surface` (Sea bright-black #373b46) so the chrome
   sits cool and unobtrusive against the Ice black background.

## Layout reshape

```
LEFT COLUMN (content only):           RIGHT COLUMN (telemetry):
  transcript (RichLog, 1fr)             thinking-current (Static, dock top)
  prompt (Input, dock bottom)           TabbedContent:
                                          Tools  (tool_start, tool_result)
                                          Debug  (thinking, worker_phase,
                                                  text_boundary)
```

The thinking-current live-preview Static moves from left → right
column so the left column is genuinely content-only. Live thinking
visibility now persists across tab switches (it docks above the
TabbedContent, not inside any tab).

## Presenter routing (TuiPresenterState.render)

Signature widens with `debug_log: RichLog`. Routing matrix:

  Text                       → log (transcript)
  Done / Error / Cancelled   → log (transcript) [terminal labels]
  ToolStart / ToolResult     → tools_log (Tools tab)
  Thinking (closed run)      → debug_log (Debug tab)
  WorkerPhase                → debug_log (Debug tab)
  TextBoundary               → debug_log (Debug tab)
  Thinking (per-delta)       → thinking_widget (live preview)

INV-009 render-exception fallback preserves routing per event class
(new INV-020) — ToolStart/Result falls back to tools_log;
Thinking/WorkerPhase/TextBoundary to debug_log; everything else to log.

## Keybindings

- Ctrl+1 → Tools tab (existing, unchanged)
- Ctrl+2 → Debug tab (NEW)

`pane-name` footer widget updates dynamically as the operator
switches tabs ("Tools" ↔ "Debug"). This was previously deferred to
"the multi-tab issue" per the Volva contract-review amendment;
multi-tab now exists, so the dynamic update lands here.

## Contract amendments

docs/contracts/issues/13.contract.md amended in-place:
- INV-015 amended: transcript is content-only; telemetry routes to
  debug_log. Old routing (telemetry in transcript) retired under the
  no-backwards-compat rule.
- INV-017 amended: thinking-current docks to right column (was left).
- INV-019 new: two TabPanes (Tools + Debug), Ctrl+1/Ctrl+2 bindings,
  dynamic pane-name update.
- INV-020 new: render-exception fallback preserves per-event-class
  routing.
- Layout-spec snapshot ASCII diagram updated.

Drift-check clean.

## Tests

239/239 GREEN (+3 new: debug_tab_exists, ctrl_2_activates_debug_tab,
pane_name_updates_on_tab_switch). 6 existing tests adjusted for the
new routing (test_thinking_closes_one_debuglog_entry,
test_multiple_thinking_runs_each_get_debuglog_entry,
test_render_exception_fallback,
test_cancelled_mid_thinking_closes,
test_worker_phase_demoted_to_debug_log,
test_left_column_content_only). ruff clean.

Live smoke against personal Worldtree: mimir KB-search turn
populated tools_log with 11 lines of tool events (search_library +
read_note); debug_log with 20 lines of worker_phase + thinking
content; transcript stayed content-only with `❯ user-prompt`
(Aurora bright-cyan) + assistant text deltas. Routing matrix
holds end-to-end. (Diagnostic note: RichLog.lines is the rendered-
output buffer; inactive TabPane content shows lines=0 until the
tab activates and renders. Internal write store is correct — this
is a Textual rendering quirk, not a routing bug.)

Minor bump (v0.4.1 → v0.5.0) per SemVer etiquette: visible routing
surface change for operators; transcript and Debug tab contents
look different from yesterday's v0.4.1.
2026-05-24 14:20:35 -07:00
vh 2756f5f1dd style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
Retheme the Textual TUI under the Australis Dark colorscheme
(github.com/lkraven/australis) — Aurora blue/cyan/green primary,
Ice neutrals (#222531 bg, #a9bcc3 fg, #cce7ec highlight), Sea
darks for chrome separators, Dawn accents reserved for terminal-
event labels (red/yellow). 16-color cool-tone palette with medium
contrast.

Mechanism: Textual `Theme` API. AUSTRALIS_THEME defined at module
scope mapped to semantic tokens (primary/secondary/accent/success/
warning/error/foreground/background/surface/panel) plus a Sea
variables block (`au-dark-30..60`, `au-bright-70/80/white`, plus
Aurora bright variants). Both `RatatoskrApp` and `AgentPickerApp`
register the theme in `__init__` and set `self.theme = "australis"`.

Per-widget styling via DEFAULT_CSS theme variables (no hex
sprinkled in CSS):

- `#identity` → `$au-bright-blue` (Aurora bright-blue).
- `#pane-name` → `$au-bright-cyan` (current-pane indicator).
- `#hint` → `$au-dark-60` (subtle Ctrl-C state line).
- `#prompt` border → `$panel` unfocused / `$primary` focused
  (focus highlight in Aurora blue).
- `#left-column` gets a `border-right: solid $panel` separator.
- `#thinking-current` color → `$au-dark-60` (matches demoted style).
- `#transcript` + `#tools-log` background → `$background`.
- Agent picker's highlighted ListItem → `$primary` bg +
  `$au-bright-white` fg.

Rich Text styling (where Textual's theme system doesn't apply):

- `_dim()` helper for demoted telemetry now uses explicit
  `#86929d` (Sea dark-60) instead of the terminal-dim filter
  `"dim"`. Renders consistently across emulators and stays
  anchored to the brand palette.
- User-prompt echo (`❯ <content>` in transcript) wrapped in
  `RichText` styled with `#42dcd1` (Aurora bright-cyan) — calls
  out the operator's voice in the primary palette.

CLI mode (`--send`) is unaffected by design — raw stdout has no
theme concept. The retheme is TUI-only.

236/236 tests GREEN; ruff clean. Live smoke against personal
Worldtree: `app.theme == "australis"`, all widget colors resolve
to expected Australis hex values (identity → `#a4c4ff`,
pane-name → `#42dcd1`, hint → `#86929d`).

One existing test adjusted: `test_worker_phase_demoted` previously
asserted `style == "dim"`; now asserts non-empty style (the demoted
style is now an explicit Australis hex, not the terminal "dim"
sentinel). Behavior-equivalent — the demotion intent is preserved.

Patch bump (v0.4.0 → v0.4.1) per SemVer etiquette: cosmetic
refinement of just-shipped surface, no public-API change.
2026-05-24 13:49:36 -07:00
6 changed files with 490 additions and 120 deletions
+22 -13
View File
@@ -154,10 +154,12 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
- **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr. - **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr.
- **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript). - **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript).
- **INV-015**: Every other event type (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keeps its issue-#12 routing target (`log` for chronological entries; `thinking_widget` for live deltas). - **INV-015** *(amended v0.5.0)*: The transcript (`log`) is **content-only** — receives `Text`, `Done` (label + Markdown body + Rule), `Error`, `Cancelled`, and the user-prompt echo (` <content>`). **All telemetry events (`Thinking` closed runs, `WorkerPhase`, `TextBoundary`) route to `debug_log` (Debug pane), NOT `log`.** Live thinking deltas continue to update `thinking_widget` per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule.
- **INV-016**: Input retains keyboard focus across `Ctrl+1` tab switch. - **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches.
- **INV-017**: `thinking-current` Static docks to the top of the **left column**, not the whole App — TabbedContent's vertical extent on the right is independent of thinking-runs starting/stopping. - **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-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`.
## TESTS (additions / changes to test_tui.py) ## TESTS (additions / changes to test_tui.py)
@@ -185,21 +187,28 @@ Existing tests that need adjustment (NOT rewrite):
All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch. All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch.
## Layout-spec snapshot (after this issue lands) ## Layout-spec snapshot (after v0.5.0)
``` ```
+────────────────────────────────+──────────────────────+ +────────────────────────────────+──────────────────────+
| · thinking-current | ┌─ Tools ─────────┐ | | user-typed line | · thinking-current |
| | │ · tool_start:.. │ | | assistant streaming text... | ┌Tools─┬─Debug─────┐|
| user-typed line | │ · tool_result.. │ | | [done] turn_id=… duration=… | │ · tool_start:.. │|
| assistant streaming text... | │ | | …markdown render… | │ · tool_result.. │|
| [done] turn_id=… duration=… | │ │ | | | │ │|
| | │ │ | | | │ │|
| | │ │ | | | │ │|
| [prompt: type and press Enter]| └─────────────────┘ | | [prompt: type and press Enter]| └─────────────────┘|
+────────────────────────────────+──────────────────────+ +────────────────────────────────+──────────────────────+
| agent · …sess_id Tools Ctrl-C twice to exit | | agent · …sess_id Tools Ctrl-C twice to exit |
+───────────────────────────────────────────────────────+ +───────────────────────────────────────────────────────+
``` ```
(Width split 2fr:1fr; tab strip is Textual-default.) Left column is content-only (transcript + prompt). Right column hosts the
live `thinking-current` Static at top + `TabbedContent` cycling between
`Tools` (tool events) and `Debug` (thinking closed runs + worker_phase +
text_boundary).
(Width split 2fr:1fr; tab strip is Textual-default; Header/Footer
backgrounds explicitly set to `$surface` to override the Textual default
`$primary`-blue tinting.)
+6 -4
View File
@@ -32,10 +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.4.0 §5 entry point: layout reshape + _As of 2026-05-24 (post-v0.5.1 UI polish pass):_
Tools pane):_
**Status: v0.4.0 shipped.** Nine core issues complete (`sse_client` **Status: v0.5.1 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)
@@ -52,7 +51,10 @@ 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.4.0 feat(tui): issue #13 — §5 layout reshape + Tools pane - v0.5.1 style(tui): polish pass — colored terminal labels, placeholders, padding
- `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) - `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1) - `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0) - `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.4.0" version = "0.5.1"
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"
+228 -46
View File
@@ -17,6 +17,7 @@ import httpx
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.theme import Theme
from textual.widgets import ( from textual.widgets import (
Footer, Footer,
Header, Header,
@@ -61,6 +62,65 @@ from ratatoskr.sse_client import (
stream_turn, stream_turn,
) )
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
#
# The Australis Dark color theme, inspired by the Southern Lights. 16 cool-tone
# terminal colors with medium contrast. Preference order: blue > cyan > green
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
# terminal-event labels (error/cancelled).
#
# Mapping to Textual's Theme semantic tokens:
# primary = Aurora blue (#6388D8) — focus rings, active selection.
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
# accent = Aurora bright cyan (#42dcd1) — bright accents (pane-name, prompt echo).
# success = Aurora green (#16B866) — [done] label.
# warning = Dawn yellow (#e1c631) — [cancelled] label.
# error = Dawn red (#ff491a) — [error] label.
# foreground = Ice white (#a9bcc3) — default text.
# background = Ice black (#222531) — App background.
# surface = Sea bright black (#373b46) — raised chrome (header/footer/input).
# panel = Sea dark 30 (#414751) — borders, separators.
AUSTRALIS_THEME = Theme(
name="australis",
primary="#6388D8",
secondary="#00b1a8",
accent="#42dcd1",
success="#16B866",
warning="#e1c631",
error="#ff491a",
foreground="#a9bcc3",
background="#222531",
surface="#373b46",
panel="#414751",
dark=True,
variables={
# Sea contrast palette — usable via $au-dark-50 etc. in TCSS.
"au-dark-30": "#414751",
"au-dark-40": "#565f69",
"au-dark-50": "#6e7882",
"au-dark-60": "#86929d",
"au-bright-70": "#9daeb6",
"au-bright-80": "#b3cbcf",
"au-bright-white": "#cce7ec",
"au-bright-blue": "#a4c4ff",
"au-bright-cyan": "#42dcd1",
"au-bright-green": "#51e08a",
},
)
# Direct hex constants for Rich Text styling (Done/Error/Cancelled labels +
# transcript user-prompt echo). Themes set TCSS variables; Rich's RichText
# style strings live outside the theme system, so we resolve to hex here.
_AU_SUCCESS = "#16B866"
_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 ------------------------- # ---- Issue #12 presenter contract semantics amendment -------------------------
# #
# TuiPresenterState replaces the stateless _render_event_to_log with a stateful # TuiPresenterState replaces the stateless _render_event_to_log with a stateful
@@ -127,16 +187,17 @@ class TuiPresenterState:
log: RichLog, log: RichLog,
thinking_widget: Static, thinking_widget: Static,
tools_log: RichLog, tools_log: RichLog,
debug_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.
Two-views-of-thinking decoupling: per-delta updates go to v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text,
`thinking_widget`; one closed entry per run goes to `log`. terminal labels ([done] / [error] / [cancelled]), and the
post-Done Markdown render. All telemetry (Thinking closed runs,
Issue #13: `ToolStart` / `ToolResult` events route to `tools_log` WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all
(the Tools pane in the right column) instead of `log`. Every other tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools
event keeps its issue-#12 routing. tab). Live thinking deltas continue to update `thinking_widget`.
Exceptions are caught at the presenter boundary (INV-009 fallback). Exceptions are caught at the presenter boundary (INV-009 fallback).
""" """
@@ -150,8 +211,14 @@ class TuiPresenterState:
from rich.text import Text as RichText from rich.text import Text as RichText
def _dim(s: str) -> RichText: def _dim(s: str) -> RichText:
"""Wrap a demoted-telemetry line in dim style for the RichLog.""" """Wrap a demoted-telemetry line in Australis dark-60 grey.
return RichText(s, style="dim")
v0.4.1 retheme: was `style="dim"` (terminal-dim filter, varies by
emulator); now explicit Australis Sea dark-60 (#86929d) so the
shade renders consistently across terminals and stays anchored to
the brand palette.
"""
return RichText(s, style=_AU_DEMOTED)
try: try:
# Thinking events: accumulate into buffer, update widget per delta. # Thinking events: accumulate into buffer, update widget per delta.
@@ -161,13 +228,19 @@ class TuiPresenterState:
self.thinking_open = True self.thinking_open = True
self.thinking_buffer.append(event.content) self.thinking_buffer.append(event.content)
acc = "".join(self.thinking_buffer) acc = "".join(self.thinking_buffer)
display_text = ("" + acc[-200:]) if len(acc) > 200 else acc # v0.5.1 polish: prefix the live widget with "thinking… " so
thinking_widget.update(display_text) # 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 return
# Non-thinking event: close any open thinking run (one RichLog entry). # 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.
if self.thinking_open: if self.thinking_open:
full_thinking = "".join(self.thinking_buffer) full_thinking = "".join(self.thinking_buffer)
log.write(_dim(f"· thinking: {full_thinking}")) debug_log.write(_dim(f"· thinking: {full_thinking}"))
self.thinking_buffer.clear() self.thinking_buffer.clear()
self.thinking_open = False self.thinking_open = False
thinking_widget.update("") thinking_widget.update("")
@@ -178,36 +251,46 @@ class TuiPresenterState:
log.write(event.content) log.write(event.content)
return return
if isinstance(event, (Done, Error, Cancelled)): if isinstance(event, (Done, Error, Cancelled)):
# Terminal events: load-bearing label (no demotion). # Terminal events: load-bearing label tinted per outcome.
# v0.5.1 polish: Aurora green / Dawn red / Dawn yellow so the
# turn-terminal status is scannable at a glance vs blending
# with default foreground.
if isinstance(event, Done): if isinstance(event, Done):
log.write( 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} "
f"duration={_format_duration_ms(event.duration_ms)} " f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='')}" f"usage {_format_usage(event.usage, arrow='')}",
) style=_AU_SUCCESS,
))
if not raw: if not raw:
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.rule import Rule from rich.rule import Rule
log.write(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(Markdown(event.response)) log.write(Markdown(event.response))
elif isinstance(event, Error): elif isinstance(event, Error):
log.write( log.write(RichText(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} " 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 else: # Cancelled
log.write( log.write(RichText(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} " 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 # Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
# terminal event, even if thinking_open was False — per STEPS 5-6. # terminal event, even if thinking_open was False — per STEPS 5-6.
thinking_widget.update("") thinking_widget.update("")
thinking_widget.display = False thinking_widget.display = False
return return
if isinstance(event, WorkerPhase): if isinstance(event, WorkerPhase):
log.write(_dim( # v0.5.0: telemetry → Debug pane, not transcript.
debug_log.write(_dim(
f"· worker_phase: phase={event.phase} turn_id={event.turn_id}" f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
)) ))
return return
@@ -225,7 +308,8 @@ class TuiPresenterState:
)) ))
return return
if isinstance(event, TextBoundary): if isinstance(event, TextBoundary):
log.write(_dim( # v0.5.0: telemetry → Debug pane, not transcript.
debug_log.write(_dim(
f"· text_boundary: kind={event.kind} char_offset={event.char_offset}" f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
)) ))
return return
@@ -234,10 +318,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.
# #
# Issue #13: routing-under-failure preservation — ToolStart/ToolResult # v0.5.0: routing-under-failure preservation — fallback writes go
# fallback writes go to tools_log (the routed destination per # to the same destination the successful render would have used:
# INV-014), not the transcript. Every other event falls back to log. # - ToolStart/ToolResult → tools_log
target = tools_log if isinstance(event, (ToolStart, ToolResult)) else log # - WorkerPhase/Thinking/TextBoundary → debug_log
# - everything else (Text/Done/Error/Cancelled) → log
if isinstance(event, (ToolStart, ToolResult)):
target = tools_log
elif isinstance(event, (WorkerPhase, Thinking, TextBoundary)):
target = debug_log
else:
target = log
target.write(_plain_label(event)) target.write(_plain_label(event))
target.write(f"[render_error] {type(exc).__name__}") target.write(f"[render_error] {type(exc).__name__}")
@@ -257,9 +348,16 @@ class AgentPickerApp(App[str | None]):
dock: top; dock: top;
height: 1; height: 1;
padding: 0 1; padding: 0 1;
color: $au-bright-cyan;
background: $surface;
} }
#agent-list { #agent-list {
height: 1fr; height: 1fr;
background: $background;
}
#agent-list > ListItem.--highlight {
background: $primary;
color: $au-bright-white;
} }
""" """
@@ -276,6 +374,8 @@ class AgentPickerApp(App[str | None]):
# [no_agents] before constructing the picker. # [no_agents] before constructing the picker.
assert agents assert agents
self.agents = agents self.agents = agents
self.register_theme(AUSTRALIS_THEME)
self.theme = "australis"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@@ -306,20 +406,38 @@ class AgentPickerApp(App[str | None]):
class RatatoskrApp(App[int]): class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane.""" """Textual TUI shell — single chat pane."""
# Issue #13: Horizontal two-column layout per design-brief §5. # Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per
# Left column (2fr) is the chat surface; right column (1fr) is the # design-brief §5. Left column (2fr) is the **content-only** chat
# TabbedContent housing side panes. v1 has only the Tools tab. # surface — assistant text, user prompt echo, [done]/[error]/[cancelled]
# terminal labels, post-Done markdown render. Right column (1fr) houses
# ALL telemetry: live thinking preview docked above TabbedContent;
# tab strip carries Tools (ToolStart/ToolResult) + Debug (Thinking
# closed runs + WorkerPhase + TextBoundary).
# #
# Dock rules narrow to per-container scope so thinking-current toggling # v0.5.0 routing change: thinking-current Static moved from left column
# in the left column doesn't reflow the right column's TabbedContent. # to right column header so the left column is genuinely content-only;
# The v0.2.1 layout-stability property is preserved within the left # closed thinking runs go to debug-log instead of transcript.
# column by docking thinking-current top + prompt bottom of that column. #
# v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface
# (Sea bright-black #373b46) overriding Textual's default $primary-blue
# tinting. TabbedContent active-tab tinting also softened.
#
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
# etc.) carry colors so a future theme swap rebinds centrally.
DEFAULT_CSS = """ DEFAULT_CSS = """
Header {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
#main-row { #main-row {
height: 1fr; height: 1fr;
} }
#left-column { #left-column {
width: 2fr; width: 2fr;
border-right: solid $panel;
} }
#right-column { #right-column {
width: 1fr; width: 1fr;
@@ -327,34 +445,66 @@ class RatatoskrApp(App[int]):
#thinking-current { #thinking-current {
dock: top; dock: top;
height: auto; height: auto;
color: $au-dark-60;
padding: 0 1;
text-style: italic;
} }
#transcript { #transcript {
height: 1fr; height: 1fr;
background: $background;
padding: 0 1;
}
#tools-log, #debug-log {
background: $background;
padding: 0 1;
}
#side-panes Tabs {
background: $surface;
}
/* Active tab: Aurora bright-cyan label so the operator's eye lands
on the currently selected pane name. */
#side-panes Tab.-active {
color: $au-bright-cyan;
text-style: bold;
} }
#prompt { #prompt {
dock: bottom; dock: bottom;
border: tall $panel;
}
#prompt:focus {
border: tall $primary;
}
/* Placeholder text in the Input — dimmer than typed content. */
#prompt > .input--placeholder {
color: $au-dark-50;
} }
#identity { #identity {
dock: bottom; dock: bottom;
height: 1; height: 1;
color: $au-bright-blue;
padding: 0 1;
} }
#pane-name { #pane-name {
dock: bottom; dock: bottom;
height: 1; height: 1;
color: $au-bright-cyan;
padding: 0 1;
} }
#hint { #hint {
dock: bottom; dock: bottom;
height: 1; height: 1;
color: $au-dark-60;
padding: 0 1;
} }
""" """
BINDINGS: ClassVar[list[Binding]] = [ BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True), Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
Binding("ctrl+d", "quit", "Exit immediately", priority=True), Binding("ctrl+d", "quit", "Exit immediately", priority=True),
# Issue #13: §5 keybinding family Ctrl+1..5 jumps between side panes # §5 keybinding family Ctrl+1..5 jumps between side panes
# without losing Input focus (INV-016). v1 only has Tools; Ctrl+2..5 # without losing Input focus (INV-016).
# land as Persona/AdminEvents/BifrostState/ServerLog panes ship.
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),
] ]
HINT_IDLE = "Ctrl-C twice to exit" HINT_IDLE = "Ctrl-C twice to exit"
@@ -378,12 +528,14 @@ class RatatoskrApp(App[int]):
self.active_turn_id: int | None = None self.active_turn_id: int | None = None
self.stream_worker = None self.stream_worker = None
self.hint: str = self.HINT_IDLE self.hint: str = self.HINT_IDLE
self.register_theme(AUSTRALIS_THEME)
self.theme = "australis"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
# Issue #13: Horizontal two-column layout. Left column = chat surface # v0.5.0 layout: left column is content-only (transcript + prompt).
# (thinking-current docked top, transcript fills middle, prompt docked # Right column houses ALL telemetry — thinking-current live preview
# bottom). Right column = TabbedContent for side panes (v1: Tools only). # docked above the TabbedContent; tabs cycle Tools / Debug.
# markup=False on RichLog so labeled lines like "[cancel_failed] ..." # markup=False on RichLog so labeled lines like "[cancel_failed] ..."
# render verbatim; Rich would otherwise interpret bracket spans as # render verbatim; Rich would otherwise interpret bracket spans as
# style markup. The post-Done markdown render uses Markdown() directly # style markup. The post-Done markdown render uses Markdown() directly
@@ -391,17 +543,21 @@ class RatatoskrApp(App[int]):
# widget-level markup=True. # 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 Static("", id="thinking-current")
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False) yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
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(
id="tools-log", wrap=True, markup=False, highlight=False id="tools-log", wrap=True, markup=False, highlight=False
) )
with TabPane("Debug", id="debug-tab"):
yield RichLog(
id="debug-log", wrap=True, markup=False, highlight=False
)
# INV-002 + INV-003: visible identity + hint widgets (Footer-area). # INV-002 + INV-003: visible identity + hint widgets (Footer-area).
# INV (issue #13): pane-name widget displays current side-pane name. # pane-name widget displays current side-pane name.
yield Static("", id="identity") yield Static("", id="identity")
yield Static("Tools", id="pane-name") yield Static("Tools", id="pane-name")
yield Static(self.HINT_IDLE, id="hint") yield Static(self.HINT_IDLE, id="hint")
@@ -420,6 +576,21 @@ class RatatoskrApp(App[int]):
self.query_one("#identity", Static).update(identity) self.query_one("#identity", Static).update(identity)
# Issue #12: thinking widget hidden until a thinking event fires. # Issue #12: thinking widget hidden until a thinking event fires.
self.query_one("#thinking-current", Static).display = False 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 telemetry — start a turn)",
style=placeholder_style)
)
self.state = "idle" self.state = "idle"
self._set_hint(self.HINT_IDLE) self._set_hint(self.HINT_IDLE)
@@ -444,7 +615,10 @@ class RatatoskrApp(App[int]):
content = event.input.value.strip() content = event.input.value.strip()
if not content: if not content:
return return
log.write(f" {content}") # noqa: RUF001 — intentional INV-006 prefix # v0.4.1 retheme: operator's voice gets Australis bright cyan so it
# stands out against the default-foreground assistant text below it.
from rich.text import Text as RichText
log.write(RichText(f" {content}", style=_AU_USER_ECHO)) # noqa: RUF001
event.input.value = "" event.input.value = ""
self.state = "streaming" self.state = "streaming"
self._set_hint(self.HINT_STREAMING) self._set_hint(self.HINT_STREAMING)
@@ -459,8 +633,9 @@ 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)
# Issue #13: tools_log routes ToolStart/ToolResult into the Tools pane. # v0.5.0: separate panes for tools vs telemetry; transcript is content only.
tools_log = self.query_one("#tools-log", RichLog) tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-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):
@@ -471,6 +646,7 @@ class RatatoskrApp(App[int]):
log=log, log=log,
thinking_widget=thinking_widget, thinking_widget=thinking_widget,
tools_log=tools_log, tools_log=tools_log,
debug_log=debug_log,
raw=self.args.raw, raw=self.args.raw,
) )
if isinstance(event, (Done, Error, Cancelled)): if isinstance(event, (Done, Error, Cancelled)):
@@ -531,6 +707,12 @@ class RatatoskrApp(App[int]):
— `test_ctrl_1_preserves_input_focus` is the regression guard. — `test_ctrl_1_preserves_input_focus` is the regression guard.
""" """
self.query_one("#side-panes", TabbedContent).active = "tools-tab" self.query_one("#side-panes", TabbedContent).active = "tools-tab"
self.query_one("#pane-name", Static).update("Tools")
def action_focus_debug(self) -> None:
"""v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus."""
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
self.query_one("#pane-name", Static).update("Debug")
def run_tui(args: ParsedArgs) -> int: def run_tui(args: ParsedArgs) -> int:
+232 -55
View File
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
def _spy_writes(monkeypatch) -> list: 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 = [] writes: list = []
original = RichLog.write original = RichLog.write
def spy(self, content, **kw): def spy(self, content, *args, **kw):
writes.append(content) writes.append(content)
return original(self, content, **kw) return original(self, content, *args, **kw)
monkeypatch.setattr(RichLog, "write", spy) monkeypatch.setattr(RichLog, "write", spy)
return writes return writes
@@ -126,6 +131,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
state.render( state.render(
@@ -133,6 +139,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
state.render( state.render(
@@ -140,24 +147,28 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, 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
# Latest call shows the full accumulated content (under 200 chars so no truncation) # Latest call shows the full accumulated content (under 200 chars so no truncation).
assert widget.update.call_args_list[-1][0][0] == "abc" # 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 # Widget became visible at first delta
assert widget.display is True assert widget.display is True
# 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_richlog_entry(self) -> None: def test_thinking_closes_one_debuglog_entry(self) -> None:
"""thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase → """thinking_closes_one_debuglog_entry [happy, v0.5.0]: 2x Thinking + WorkerPhase →
RichLog has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden. debug_log has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden;
transcript (log) untouched.
""" """
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
log = MagicMock() log = MagicMock()
debug_log = MagicMock()
widget = MagicMock() widget = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
state.render( state.render(
@@ -165,6 +176,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
state.render( state.render(
@@ -172,6 +184,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
state.render( state.render(
@@ -179,14 +192,16 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..." # v0.5.0: closure + worker_phase write to debug_log; transcript untouched.
assert log.write.call_count == 2 assert debug_log.write.call_count == 2
assert not log.write.called
# First write = closed thinking entry containing the full accumulated text # First write = closed thinking entry containing the full accumulated text
assert "· thinking: ab" in log.write.call_args_list[0][0][0] assert "· thinking: ab" in _text_of(debug_log.write.call_args_list[0][0][0])
# Second write = worker_phase with demotion prefix # Second write = worker_phase with demotion prefix (also in debug_log)
assert "· worker_phase:" in log.write.call_args_list[1][0][0] 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
@@ -205,12 +220,15 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
last_update = widget.update.call_args_list[-1][0][0] last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars # v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
assert last_update.startswith("") assert last_update.startswith("thinking… ")
assert len(last_update) == 201 # 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: def test_thinking_widget_visibility_lifecycle(self) -> None:
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking; """thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
@@ -228,6 +246,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
assert widget.display is True assert widget.display is True
@@ -237,17 +256,20 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
assert widget.display is False assert widget.display is False
def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None: def test_multiple_thinking_runs_each_get_debuglog_entry(self) -> None:
"""multiple_thinking_runs_each_get_richlog_entry [scenario]: """multiple_thinking_runs_each_get_debuglog_entry [scenario, v0.5.0]:
Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries. Thinking → Text → Thinking → Done → TWO closed thinking entries in debug_log
(transcript receives only the Text + Done content).
""" """
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
log = MagicMock() log = MagicMock()
debug_log = MagicMock()
widget = MagicMock() widget = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
state.render( state.render(
@@ -255,6 +277,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
state.render( state.render(
@@ -262,6 +285,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
state.render( state.render(
@@ -269,46 +293,62 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
# Close the second run with a Done. # Close the second run with a Done.
state.render( state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True _make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=True,
) )
# Count closed thinking entries — now dim RichText; plain text starts with "· thinking:". # v0.5.0: closed thinking entries land in debug_log, NOT log.
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list] 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:")] thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
assert len(thinking_entries) == 2 assert len(thinking_entries) == 2
assert "first" in thinking_entries[0] assert "first" in thinking_entries[0]
assert "second" in thinking_entries[1] assert "second" in thinking_entries[1]
# transcript receives: "hi" (Text) + "[done] ..." (terminal label) only.
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: def test_render_exception_fallback(self) -> None:
"""render_exception_fallback [adversarial]: """render_exception_fallback [adversarial, v0.5.0]:
widget.update raises → RichLog gets BOTH a plain-labeled fallback line for widget.update raises → debug_log gets BOTH a plain-labeled fallback line
the original event AND a `[render_error] <ExceptionClassName>` line for the original Thinking event AND a `[render_error] <ExceptionClassName>`
(NO exception message per INV-009 security clause); state does NOT propagate. line (NO exception message per INV-009 security clause). transcript
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()
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()
# Should not raise; should write a fallback labeled line + a [render_error] line.
state.render( state.render(
Thinking(sse_id=SID, content="x"), Thinking(sse_id=SID, content="x"),
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)] 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 (pre-amendment shape). # 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. # 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). # 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
def test_state_reset_per_worker(self) -> None: def test_state_reset_per_worker(self) -> None:
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open.""" """state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
@@ -320,6 +360,7 @@ class TestTuiPresenterState:
log=MagicMock(), log=MagicMock(),
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
s2 = TuiPresenterState() s2 = TuiPresenterState()
@@ -327,12 +368,14 @@ class TestTuiPresenterState:
assert s2.thinking_open is False assert s2.thinking_open is False
def test_cancelled_mid_thinking_closes(self) -> None: def test_cancelled_mid_thinking_closes(self) -> None:
"""cancelled_mid_thinking_closes [scenario]: """cancelled_mid_thinking_closes [scenario, v0.5.0]:
Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden. Thinking, Cancelled → ONE closed thinking entry in debug_log + a
[cancelled] entry in transcript; widget hidden.
""" """
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
log = MagicMock() log = MagicMock()
debug_log = MagicMock()
widget = MagicMock() widget = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
state.render( state.render(
@@ -340,6 +383,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
state.render( state.render(
@@ -349,12 +393,14 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
# Closed thinking entries are now dim RichText; terminal labels are plain str. # v0.5.0: closed thinking entry lands in debug_log; terminal [cancelled] in transcript.
writes = [_text_of(c[0][0]) for c in log.write.call_args_list] debug_writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
assert any(w.startswith("· thinking: partial") for w in writes) log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("[cancelled]") for w in writes) 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 assert widget.display is False
def test_done_renders_markdown_after_label(self) -> None: def test_done_renders_markdown_after_label(self) -> None:
@@ -374,16 +420,22 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
state.render( state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False _make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
) )
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. # Text stream wrote "hi" with no prefix.
assert "hi" in writes assert "hi" in writes
# [done] label wrote. # v0.5.1: [done] label is now RichText (Aurora green); plain content test via _text_of.
assert any(isinstance(w, str) and 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). # 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)
@@ -403,36 +455,49 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=widget, thinking_widget=widget,
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True, raw=True,
) )
state.render( state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True _make_tui_done(),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_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)
assert not any(isinstance(w, Markdown) for w in writes) assert not any(isinstance(w, Markdown) for w in writes)
def test_worker_phase_demoted(self) -> None: def test_worker_phase_demoted_to_debug_log(self) -> None:
"""worker_phase_demoted [trace]: WorkerPhase → RichLog "· worker_phase:" prefix """worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
rendered with dim Rich style (INV-003: dim style + `· ` prefix in TUI). "· worker_phase:" prefix rendered with Australis dark-60 Rich style.
Transcript receives nothing.
""" """
from rich.text import Text as RichText from rich.text import Text as RichText
from ratatoskr.tui import TuiPresenterState from ratatoskr.tui import TuiPresenterState
log = MagicMock() log = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState() state = TuiPresenterState()
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=MagicMock(), thinking_widget=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log,
raw=False, raw=False,
) )
renderable = log.write.call_args[0][0] # v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
# INV-003: must be a dim-styled Rich Text renderable, not a plain str. assert not log.write.called
renderable = debug_log.write.call_args[0][0]
# INV-003: must be a styled Rich Text renderable, not a plain str.
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
# of the terminal-dim filter "dim". Assert non-empty styling either way.
assert isinstance(renderable, RichText), type(renderable) assert isinstance(renderable, RichText), type(renderable)
assert renderable.style == "dim" assert renderable.style, "demoted telemetry must carry SOME style"
text = renderable.plain text = renderable.plain
assert text.startswith("· worker_phase:") assert text.startswith("· worker_phase:")
assert "[worker_phase]" not in text assert "[worker_phase]" not in text
@@ -456,7 +521,14 @@ class TestTuiPresenterState:
widget.display = True # pre-set to non-default to detect the clear widget.display = True # pre-set to non-default to detect the clear
state = TuiPresenterState() state = TuiPresenterState()
# thinking_open is False (state just constructed). # thinking_open is False (state just constructed).
state.render(terminal, log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True) state.render(
terminal,
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_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("")
assert widget.display is False, type(terminal).__name__ assert widget.display is False, type(terminal).__name__
@@ -478,6 +550,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=tools_log, tools_log=tools_log,
debug_log=MagicMock(),
raw=False, raw=False,
) )
# INV-014: write went to tools_log # INV-014: write went to tools_log
@@ -498,6 +571,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=tools_log, tools_log=tools_log,
debug_log=MagicMock(),
raw=False, raw=False,
) )
assert tools_log.write.called assert tools_log.write.called
@@ -516,6 +590,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=tools_log, tools_log=tools_log,
debug_log=MagicMock(),
raw=False, raw=False,
) )
assert log.write.called assert log.write.called
@@ -534,6 +609,7 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False, raw=False,
) )
line = log.write.call_args[0][0] line = log.write.call_args[0][0]
@@ -551,12 +627,13 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True, raw=True,
) )
done_line = next( done_line = next(
c[0][0] _text_of(c[0][0])
for c in log.write.call_args_list 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=5.5s" in done_line
assert "duration_ms=5467" not in done_line assert "duration_ms=5467" not in done_line
@@ -578,12 +655,13 @@ class TestTuiPresenterState:
log=log, log=log,
thinking_widget=MagicMock(), thinking_widget=MagicMock(),
tools_log=MagicMock(), tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True, raw=True,
) )
done_line = next( done_line = next(
c[0][0] _text_of(c[0][0])
for c in log.write.call_args_list 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 assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -736,8 +814,12 @@ class TestLayoutShape:
row = app.query_one("#main-row", Horizontal) row = app.query_one("#main-row", Horizontal)
assert row is not None assert row is not None
async def test_left_column_has_transcript_and_prompt(self) -> None: async def test_left_column_content_only(self) -> None:
"""left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking.""" """left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
thinking-current Static moved to right column so the left column is
genuinely content-only (transcript + prompt input).
"""
from textual.containers import Vertical from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static from textual.widgets import Input, RichLog, Static
@@ -745,14 +827,15 @@ class TestLayoutShape:
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.pause() await pilot.pause()
left = app.query_one("#left-column", Vertical) left = app.query_one("#left-column", Vertical)
assert left is not None right = app.query_one("#right-column", Vertical)
transcript = app.query_one("#transcript", RichLog) transcript = app.query_one("#transcript", RichLog)
prompt = app.query_one("#prompt", Input) prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static) thinking = app.query_one("#thinking-current", Static)
# Widgets are inside the left column (descendant check)
assert transcript in left.walk_children() assert transcript in left.walk_children()
assert prompt in left.walk_children() assert prompt in left.walk_children()
assert thinking in left.walk_children() # v0.5.0: thinking-current is now under the right column, NOT left.
assert thinking not in left.walk_children()
assert thinking in right.walk_children()
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None: async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab.""" """right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
@@ -817,6 +900,100 @@ class TestLayoutShape:
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}" f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
) )
async def test_debug_tab_exists(self) -> None:
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog."""
from textual.widgets import RichLog, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
debug_tab = app.query_one("#debug-tab", TabPane)
debug_log = app.query_one("#debug-log", RichLog)
assert debug_log in debug_tab.walk_children()
async def test_ctrl_2_activates_debug_tab(self) -> None:
"""ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'."""
from textual.widgets import TabbedContent
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+2")
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),
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 "waiting for telemetry" 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.
Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug"
and back as the operator switches via Ctrl+1 / Ctrl+2.
"""
from textual.widgets import Static
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
pane_name = app.query_one("#pane-name", Static)
await pilot.press("ctrl+2")
await pilot.pause()
assert str(pane_name.render()) == "Debug"
await pilot.press("ctrl+1")
await pilot.pause()
assert str(pane_name.render()) == "Tools"
import asyncio # noqa: E402 import asyncio # noqa: E402
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.4.0" version = "0.5.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },