Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffd22fb587 | |||
| 2756f5f1dd |
@@ -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.)
|
||||||
|
|||||||
@@ -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.0 content-only main pane + Debug tab):_
|
||||||
Tools pane):_
|
|
||||||
|
|
||||||
**Status: v0.4.0 shipped.** Nine core issues complete (`sse_client`
|
**Status: v0.5.0 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,9 @@ 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.0 refactor(tui): content-only main pane + Debug tab + chrome dark
|
||||||
|
- `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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
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"
|
||||||
|
|||||||
+173
-33
@@ -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,64 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
# ---- 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 +186,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 +210,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.
|
||||||
@@ -164,10 +230,12 @@ class TuiPresenterState:
|
|||||||
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||||
thinking_widget.update(display_text)
|
thinking_widget.update(display_text)
|
||||||
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("")
|
||||||
@@ -207,7 +275,8 @@ class TuiPresenterState:
|
|||||||
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 +294,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 +304,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 +334,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 +360,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 +392,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 +431,53 @@ class RatatoskrApp(App[int]):
|
|||||||
#thinking-current {
|
#thinking-current {
|
||||||
dock: top;
|
dock: top;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
color: $au-dark-60;
|
||||||
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
#transcript {
|
#transcript {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
|
background: $background;
|
||||||
|
}
|
||||||
|
#tools-log, #debug-log {
|
||||||
|
background: $background;
|
||||||
|
}
|
||||||
|
#side-panes Tabs {
|
||||||
|
background: $surface;
|
||||||
}
|
}
|
||||||
#prompt {
|
#prompt {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
|
border: tall $panel;
|
||||||
|
}
|
||||||
|
#prompt:focus {
|
||||||
|
border: tall $primary;
|
||||||
}
|
}
|
||||||
#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 +501,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 +516,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")
|
||||||
@@ -444,7 +573,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 +591,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 +604,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 +665,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:
|
||||||
|
|||||||
+157
-41
@@ -126,6 +126,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 +134,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,6 +142,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,
|
||||||
)
|
)
|
||||||
# Widget updated 3 times — once per delta — with cumulative content
|
# Widget updated 3 times — once per delta — with cumulative content
|
||||||
@@ -151,13 +154,15 @@ class TestTuiPresenterState:
|
|||||||
# No RichLog write yet — closure hasn't fired
|
# No RichLog write yet — closure hasn't fired
|
||||||
assert log.write.call_count == 0
|
assert log.write.call_count == 0
|
||||||
|
|
||||||
def test_thinking_closes_one_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 +170,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 +178,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 +186,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,6 +214,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,
|
||||||
)
|
)
|
||||||
last_update = widget.update.call_args_list[-1][0][0]
|
last_update = widget.update.call_args_list[-1][0][0]
|
||||||
@@ -228,6 +238,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 +248,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 +269,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 +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(
|
||||||
@@ -269,46 +285,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 +352,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 +360,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 +375,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 +385,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,10 +412,16 @@ 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.
|
||||||
@@ -403,36 +447,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 +513,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 +542,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 +563,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 +582,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 +601,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,6 +619,7 @@ 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(
|
||||||
@@ -578,6 +647,7 @@ 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(
|
||||||
@@ -736,8 +806,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 +819,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 +892,47 @@ 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_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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user