Compare commits

...

2 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
6 changed files with 387 additions and 119 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-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-016**: Input retains keyboard focus across `Ctrl+1` tab switch.
- **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-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` / `Ctrl+2` tab switches.
- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
- **INV-019** *(new v0.5.0)*: Two TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`). Ctrl+1 activates Tools; Ctrl+2 activates Debug. `pane-name` Static reflects the active tab name dynamically.
- **INV-020** *(new v0.5.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` fallback writes to `tools_log`; `WorkerPhase` / `Thinking` / `TextBoundary` fallback writes to `debug_log`; everything else falls back to `log`.
## 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.
## Layout-spec snapshot (after this issue lands)
## Layout-spec snapshot (after v0.5.0)
```
+────────────────────────────────+──────────────────────+
| · thinking-current | ┌─ Tools ─────────┐ |
| | │ · tool_start:.. │ |
| user-typed line | │ · tool_result.. │ |
| assistant streaming text... | │ |
| [done] turn_id=… duration=… | │ │ |
| | │ │ |
| | │ │ |
| [prompt: type and press Enter]| └─────────────────┘ |
| user-typed line | · thinking-current |
| assistant streaming text... | ┌Tools─┬─Debug─────┐|
| [done] turn_id=… duration=… | │ · tool_start:.. │|
| …markdown render… | │ · tool_result.. │|
| | │ │|
| | │ │|
| | │ │|
| [prompt: type and press Enter]| └─────────────────┘|
+────────────────────────────────+──────────────────────+
| 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.)
+5 -3
View File
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-24 (post-v0.4.1 Australis theme retheme):_
_As of 2026-05-24 (post-v0.5.1 UI polish pass):_
**Status: v0.4.1 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
startup error visibility #6, presenter contract semantics amendment
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
@@ -51,7 +51,9 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
land). CLI mode (--send) unaffected by design — INV-018.
Last commits on `main`:
- v0.4.1 style(tui): apply Australis theme to TUI chrome + widgets
- 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)
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.4.1"
version = "0.5.1"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+130 -48
View File
@@ -118,6 +118,7 @@ _AU_ERROR = "#ff491a"
_AU_WARNING = "#e1c631"
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
# ---- Issue #12 presenter contract semantics amendment -------------------------
@@ -186,16 +187,17 @@ class TuiPresenterState:
log: RichLog,
thinking_widget: Static,
tools_log: RichLog,
debug_log: RichLog,
raw: bool,
) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
Two-views-of-thinking decoupling: per-delta updates go to
`thinking_widget`; one closed entry per run goes to `log`.
Issue #13: `ToolStart` / `ToolResult` events route to `tools_log`
(the Tools pane in the right column) instead of `log`. Every other
event keeps its issue-#12 routing.
v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text,
terminal labels ([done] / [error] / [cancelled]), and the
post-Done Markdown render. All telemetry (Thinking closed runs,
WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all
tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools
tab). Live thinking deltas continue to update `thinking_widget`.
Exceptions are caught at the presenter boundary (INV-009 fallback).
"""
@@ -226,13 +228,19 @@ class TuiPresenterState:
self.thinking_open = True
self.thinking_buffer.append(event.content)
acc = "".join(self.thinking_buffer)
display_text = ("" + acc[-200:]) if len(acc) > 200 else acc
thinking_widget.update(display_text)
# v0.5.1 polish: prefix the live widget with "thinking… " so
# operators recognize what the streaming content is (otherwise
# the static-content under Header reads like uncontextualized
# spillover). Truncate display to last 200 chars + ellipsis.
tail = ("" + acc[-200:]) if len(acc) > 200 else acc
thinking_widget.update(f"thinking… {tail}")
return
# Non-thinking event: close any open thinking run (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:
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_open = False
thinking_widget.update("")
@@ -243,36 +251,46 @@ class TuiPresenterState:
log.write(event.content)
return
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):
log.write(
log.write(RichText(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='')}"
)
f"usage {_format_usage(event.usage, arrow='')}",
style=_AU_SUCCESS,
))
if not raw:
from rich.markdown import Markdown
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))
elif isinstance(event, Error):
log.write(
log.write(RichText(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r}"
)
f"message={event.message!r}",
style=_AU_ERROR,
))
else: # Cancelled
log.write(
log.write(RichText(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}"
)
f"partial_message_id={event.partial_message_id}",
style=_AU_WARNING,
))
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
# terminal event, even if thinking_open was False — per STEPS 5-6.
thinking_widget.update("")
thinking_widget.display = False
return
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}"
))
return
@@ -290,7 +308,8 @@ class TuiPresenterState:
))
return
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}"
))
return
@@ -299,10 +318,17 @@ class TuiPresenterState:
# the original event AND a render_error line with the class name only
# (NO exception message — security clause). Volva F1 fix.
#
# Issue #13: routing-under-failure preservation — ToolStart/ToolResult
# fallback writes go to tools_log (the routed destination per
# INV-014), not the transcript. Every other event falls back to log.
target = tools_log if isinstance(event, (ToolStart, ToolResult)) else log
# v0.5.0: routing-under-failure preservation — fallback writes go
# to the same destination the successful render would have used:
# - ToolStart/ToolResult → tools_log
# - WorkerPhase/Thinking/TextBoundary → debug_log
# - everything else (Text/Done/Error/Cancelled) → log
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(f"[render_error] {type(exc).__name__}")
@@ -380,20 +406,32 @@ class AgentPickerApp(App[str | None]):
class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane."""
# Issue #13: Horizontal two-column layout per design-brief §5.
# Left column (2fr) is the chat surface; right column (1fr) is the
# TabbedContent housing side panes. v1 has only the Tools tab.
# Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per
# design-brief §5. Left column (2fr) is the **content-only** chat
# 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
# in the left column doesn't reflow the right column's TabbedContent.
# The v0.2.1 layout-stability property is preserved within the left
# column by docking thinking-current top + prompt bottom of that column.
# v0.5.0 routing change: thinking-current Static moved from left column
# to right column header so the left column is genuinely content-only;
# closed thinking runs go to debug-log instead of transcript.
#
# v0.4.1: Australis theme — Aurora blue/cyan primary, Ice background,
# Sea darks for chrome separators. Widget colors use theme variables
# ($primary/$accent/$au-dark-60/$au-bright-cyan/etc.) so a future theme
# swap rebinds them centrally.
# 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 = """
Header {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
#main-row {
height: 1fr;
}
@@ -408,13 +446,26 @@ class RatatoskrApp(App[int]):
dock: top;
height: auto;
color: $au-dark-60;
padding: 0 1;
text-style: italic;
}
#transcript {
height: 1fr;
background: $background;
padding: 0 1;
}
#tools-log {
#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 {
dock: bottom;
@@ -423,6 +474,10 @@ class RatatoskrApp(App[int]):
#prompt:focus {
border: tall $primary;
}
/* Placeholder text in the Input — dimmer than typed content. */
#prompt > .input--placeholder {
color: $au-dark-50;
}
#identity {
dock: bottom;
height: 1;
@@ -446,10 +501,10 @@ class RatatoskrApp(App[int]):
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
# Issue #13: §5 keybinding family Ctrl+1..5 jumps between side panes
# without losing Input focus (INV-016). v1 only has Tools; Ctrl+2..5
# land as Persona/AdminEvents/BifrostState/ServerLog panes ship.
# §5 keybinding family Ctrl+1..5 jumps between side panes
# without losing Input focus (INV-016).
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"
@@ -478,9 +533,9 @@ class RatatoskrApp(App[int]):
def compose(self) -> ComposeResult:
yield Header()
# Issue #13: Horizontal two-column layout. Left column = chat surface
# (thinking-current docked top, transcript fills middle, prompt docked
# bottom). Right column = TabbedContent for side panes (v1: Tools only).
# v0.5.0 layout: left column is content-only (transcript + prompt).
# Right column houses ALL telemetry — thinking-current live preview
# docked above the TabbedContent; tabs cycle Tools / Debug.
# markup=False on RichLog so labeled lines like "[cancel_failed] ..."
# render verbatim; Rich would otherwise interpret bracket spans as
# style markup. The post-Done markdown render uses Markdown() directly
@@ -488,17 +543,21 @@ class RatatoskrApp(App[int]):
# widget-level markup=True.
with Horizontal(id="main-row"):
with Vertical(id="left-column"):
yield Static("", id="thinking-current")
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
yield Input(id="prompt", placeholder="Type a message and press Enter")
with Vertical(id="right-column"):
yield Static("", id="thinking-current")
with TabbedContent(id="side-panes"):
with TabPane("Tools", id="tools-tab"):
yield RichLog(
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 (issue #13): pane-name widget displays current side-pane name.
# pane-name widget displays current side-pane name.
yield Static("", id="identity")
yield Static("Tools", id="pane-name")
yield Static(self.HINT_IDLE, id="hint")
@@ -517,6 +576,21 @@ class RatatoskrApp(App[int]):
self.query_one("#identity", Static).update(identity)
# Issue #12: thinking widget hidden until a thinking event fires.
self.query_one("#thinking-current", Static).display = False
# v0.5.1 polish: empty-state placeholder lines so the operator sees
# the pane is intentionally empty (not broken) before any turn fires.
# Wrapped in Australis dark-50 italic so they read distinctly as
# placeholder text, not real telemetry. Disappear naturally as the
# log fills with real events (the placeholders scroll off the top).
from rich.text import Text as RichText
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
self.query_one("#tools-log", RichLog).write(
RichText("(no tool events yet — start a turn that uses tools)",
style=placeholder_style)
)
self.query_one("#debug-log", RichLog).write(
RichText("(waiting for telemetry — start a turn)",
style=placeholder_style)
)
self.state = "idle"
self._set_hint(self.HINT_IDLE)
@@ -559,8 +633,9 @@ class RatatoskrApp(App[int]):
assert content
log = self.query_one("#transcript", RichLog)
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)
debug_log = self.query_one("#debug-log", RichLog)
presenter = TuiPresenterState()
try:
async for event in stream_turn(self.client, self.session_id, content):
@@ -571,6 +646,7 @@ class RatatoskrApp(App[int]):
log=log,
thinking_widget=thinking_widget,
tools_log=tools_log,
debug_log=debug_log,
raw=self.args.raw,
)
if isinstance(event, (Done, Error, Cancelled)):
@@ -631,6 +707,12 @@ class RatatoskrApp(App[int]):
— `test_ctrl_1_preserves_input_focus` is the regression guard.
"""
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:
+228 -53
View File
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
def _spy_writes(monkeypatch) -> list:
"""Patch RichLog.write to record every arg into a list (returned)."""
"""Patch RichLog.write to record every arg into a list (returned).
Accepts *args/**kwargs so Textual's internal deferred-render path
(which calls write positionally with width/expand/shrink/scroll_end)
still works after a write-during-mount + Resize sequence.
"""
writes: list = []
original = RichLog.write
def spy(self, content, **kw):
def spy(self, content, *args, **kw):
writes.append(content)
return original(self, content, **kw)
return original(self, content, *args, **kw)
monkeypatch.setattr(RichLog, "write", spy)
return writes
@@ -126,6 +131,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
state.render(
@@ -133,6 +139,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
state.render(
@@ -140,24 +147,28 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
# Widget updated 3 times — once per delta — with cumulative content
assert widget.update.call_count == 3
# Latest call shows the full accumulated content (under 200 chars so no truncation)
assert widget.update.call_args_list[-1][0][0] == "abc"
# Latest call shows the full accumulated content (under 200 chars so no truncation).
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
# Widget became visible at first delta
assert widget.display is True
# No RichLog write yet — closure hasn't fired
assert log.write.call_count == 0
def test_thinking_closes_one_richlog_entry(self) -> None:
"""thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase →
RichLog has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden.
def test_thinking_closes_one_debuglog_entry(self) -> None:
"""thinking_closes_one_debuglog_entry [happy, v0.5.0]: 2x Thinking + WorkerPhase →
debug_log has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden;
transcript (log) untouched.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
@@ -165,6 +176,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
@@ -172,6 +184,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
@@ -179,14 +192,16 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
assert log.write.call_count == 2
# v0.5.0: closure + worker_phase write to debug_log; transcript untouched.
assert debug_log.write.call_count == 2
assert not log.write.called
# First write = closed thinking entry containing the full accumulated text
assert "· thinking: ab" in log.write.call_args_list[0][0][0]
# Second write = worker_phase with demotion prefix
assert "· worker_phase:" in log.write.call_args_list[1][0][0]
assert "· thinking: ab" in _text_of(debug_log.write.call_args_list[0][0][0])
# Second write = worker_phase with demotion prefix (also in debug_log)
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[1][0][0])
# Widget cleared + hidden
widget.update.assert_called_with("")
assert widget.display is False
@@ -205,12 +220,15 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars
assert last_update.startswith("")
assert len(last_update) == 201
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
assert last_update.startswith("thinking… ")
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
assert len(last_update) == len("thinking… ") + 201
assert "" in last_update
def test_thinking_widget_visibility_lifecycle(self) -> None:
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
@@ -228,6 +246,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
assert widget.display is True
@@ -237,17 +256,20 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
assert widget.display is False
def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None:
"""multiple_thinking_runs_each_get_richlog_entry [scenario]:
Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries.
def test_multiple_thinking_runs_each_get_debuglog_entry(self) -> None:
"""multiple_thinking_runs_each_get_debuglog_entry [scenario, v0.5.0]:
Thinking → Text → Thinking → Done → TWO closed thinking entries in debug_log
(transcript receives only the Text + Done content).
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
@@ -255,6 +277,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
@@ -262,6 +285,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
@@ -269,46 +293,62 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
# Close the second run with a Done.
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:".
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list]
# v0.5.0: closed thinking entries land in debug_log, NOT log.
thinking_entries = [_text_of(call[0][0]) for call in debug_log.write.call_args_list]
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
assert len(thinking_entries) == 2
assert "first" in thinking_entries[0]
assert "second" in thinking_entries[1]
# transcript receives: "hi" (Text) + "[done] ..." (terminal label) only.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert "hi" in log_writes
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
def test_render_exception_fallback(self) -> None:
"""render_exception_fallback [adversarial]:
widget.update raises → RichLog gets BOTH a plain-labeled fallback line for
the original event AND a `[render_error] <ExceptionClassName>` line
(NO exception message per INV-009 security clause); state does NOT propagate.
"""render_exception_fallback [adversarial, v0.5.0]:
widget.update raises → debug_log gets BOTH a plain-labeled fallback line
for the original Thinking event AND a `[render_error] <ExceptionClassName>`
line (NO exception message per INV-009 security clause). transcript
receives nothing — routing preservation under failure (debug-shaped
event falls back to debug_log).
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
widget = MagicMock()
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
state = TuiPresenterState()
# Should not raise; should write a fallback labeled line + a [render_error] line.
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)]
# POST-007: plain-label fallback for the original Thinking event (pre-amendment shape).
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.
assert any(w.startswith("[thinking]") for w in writes), writes
# POST-007: render_error line with class name ONLY.
assert any(w == "[render_error] AttributeError" for w in writes), writes
# Critical: exception message MUST NOT appear in any write (INV-009 security).
assert not any("widget gone" in w for w in writes), writes
# v0.5.0 routing preservation: transcript receives NOTHING on a
# debug-shaped event's failure path.
assert not log.write.called
def test_state_reset_per_worker(self) -> None:
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
@@ -320,6 +360,7 @@ class TestTuiPresenterState:
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
s2 = TuiPresenterState()
@@ -327,12 +368,14 @@ class TestTuiPresenterState:
assert s2.thinking_open is False
def test_cancelled_mid_thinking_closes(self) -> None:
"""cancelled_mid_thinking_closes [scenario]:
Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden.
"""cancelled_mid_thinking_closes [scenario, v0.5.0]:
Thinking, Cancelled → ONE closed thinking entry in debug_log + a
[cancelled] entry in transcript; widget hidden.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
@@ -340,6 +383,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
state.render(
@@ -349,12 +393,14 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
# Closed thinking entries are now dim RichText; terminal labels are plain str.
writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("· thinking: partial") for w in writes)
assert any(w.startswith("[cancelled]") for w in writes)
# v0.5.0: closed thinking entry lands in debug_log; terminal [cancelled] in transcript.
debug_writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("· thinking: partial") for w in debug_writes)
assert any(w.startswith("[cancelled]") for w in log_writes)
assert widget.display is False
def test_done_renders_markdown_after_label(self) -> None:
@@ -374,16 +420,22 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
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]
# Text stream wrote "hi" with no prefix.
assert "hi" in writes
# [done] label wrote.
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
# 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)
# 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, Markdown) for w in writes)
@@ -403,33 +455,44 @@ class TestTuiPresenterState:
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
)
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]
assert not any(isinstance(w, Rule) for w in writes)
assert not any(isinstance(w, Markdown) for w in writes)
def test_worker_phase_demoted(self) -> None:
"""worker_phase_demoted [trace]: WorkerPhase → RichLog "· worker_phase:" prefix
rendered with dim Rich style (INV-003: dim style + `· ` prefix in TUI).
def test_worker_phase_demoted_to_debug_log(self) -> None:
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
"· worker_phase:" prefix rendered with Australis dark-60 Rich style.
Transcript receives nothing.
"""
from rich.text import Text as RichText
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
raw=False,
)
renderable = log.write.call_args[0][0]
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
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.
@@ -458,7 +521,14 @@ class TestTuiPresenterState:
widget.display = True # pre-set to non-default to detect the clear
state = TuiPresenterState()
# thinking_open is False (state just constructed).
state.render(terminal, log=log, thinking_widget=widget, tools_log=MagicMock(), 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.
widget.update.assert_called_with("")
assert widget.display is False, type(terminal).__name__
@@ -480,6 +550,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
raw=False,
)
# INV-014: write went to tools_log
@@ -500,6 +571,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
raw=False,
)
assert tools_log.write.called
@@ -518,6 +590,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
debug_log=MagicMock(),
raw=False,
)
assert log.write.called
@@ -536,6 +609,7 @@ class TestTuiPresenterState:
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=False,
)
line = log.write.call_args[0][0]
@@ -553,12 +627,13 @@ class TestTuiPresenterState:
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0]
_text_of(c[0][0])
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
if _text_of(c[0][0]).startswith("[done]")
)
assert "duration=5.5s" in done_line
assert "duration_ms=5467" not in done_line
@@ -580,12 +655,13 @@ class TestTuiPresenterState:
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0]
_text_of(c[0][0])
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
if _text_of(c[0][0]).startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -738,8 +814,12 @@ class TestLayoutShape:
row = app.query_one("#main-row", Horizontal)
assert row is not None
async def test_left_column_has_transcript_and_prompt(self) -> None:
"""left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking."""
async def test_left_column_content_only(self) -> None:
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
thinking-current Static moved to right column so the left column is
genuinely content-only (transcript + prompt input).
"""
from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static
@@ -747,14 +827,15 @@ class TestLayoutShape:
async with app.run_test() as pilot:
await pilot.pause()
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)
prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static)
# Widgets are inside the left column (descendant check)
assert transcript 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:
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
@@ -819,6 +900,100 @@ class TestLayoutShape:
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
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.4.1"
version = "0.5.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },