Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7106af5c09 | |||
| ffd22fb587 |
@@ -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,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
|||||||
|
|
||||||
## Current state / in-flight
|
## Current state / in-flight
|
||||||
|
|
||||||
_As of 2026-05-24 (post-v0.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
|
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||||
startup error visibility #6, presenter contract semantics amendment
|
startup error visibility #6, presenter contract semantics amendment
|
||||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||||
@@ -51,7 +51,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.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)
|
- `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)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+130
-48
@@ -118,6 +118,7 @@ _AU_ERROR = "#ff491a"
|
|||||||
_AU_WARNING = "#e1c631"
|
_AU_WARNING = "#e1c631"
|
||||||
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
|
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
|
||||||
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
|
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
|
||||||
|
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
|
||||||
|
|
||||||
|
|
||||||
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
||||||
@@ -186,16 +187,17 @@ class TuiPresenterState:
|
|||||||
log: RichLog,
|
log: RichLog,
|
||||||
thinking_widget: Static,
|
thinking_widget: Static,
|
||||||
tools_log: RichLog,
|
tools_log: RichLog,
|
||||||
|
debug_log: RichLog,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||||
|
|
||||||
Two-views-of-thinking decoupling: per-delta updates go to
|
v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text,
|
||||||
`thinking_widget`; one closed entry per run goes to `log`.
|
terminal labels ([done] / [error] / [cancelled]), and the
|
||||||
|
post-Done Markdown render. All telemetry (Thinking closed runs,
|
||||||
Issue #13: `ToolStart` / `ToolResult` events route to `tools_log`
|
WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all
|
||||||
(the Tools pane in the right column) instead of `log`. Every other
|
tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools
|
||||||
event keeps its issue-#12 routing.
|
tab). Live thinking deltas continue to update `thinking_widget`.
|
||||||
|
|
||||||
Exceptions are caught at the presenter boundary (INV-009 fallback).
|
Exceptions are caught at the presenter boundary (INV-009 fallback).
|
||||||
"""
|
"""
|
||||||
@@ -226,13 +228,19 @@ class TuiPresenterState:
|
|||||||
self.thinking_open = True
|
self.thinking_open = True
|
||||||
self.thinking_buffer.append(event.content)
|
self.thinking_buffer.append(event.content)
|
||||||
acc = "".join(self.thinking_buffer)
|
acc = "".join(self.thinking_buffer)
|
||||||
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
# v0.5.1 polish: prefix the live widget with "thinking… " so
|
||||||
thinking_widget.update(display_text)
|
# operators recognize what the streaming content is (otherwise
|
||||||
|
# the static-content under Header reads like uncontextualized
|
||||||
|
# spillover). Truncate display to last 200 chars + ellipsis.
|
||||||
|
tail = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||||
|
thinking_widget.update(f"thinking… {tail}")
|
||||||
return
|
return
|
||||||
# Non-thinking event: close any open thinking run (one RichLog entry).
|
# Non-thinking event: close any open thinking run.
|
||||||
|
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
|
||||||
|
# transcript — keeps the main pane content-only.
|
||||||
if self.thinking_open:
|
if self.thinking_open:
|
||||||
full_thinking = "".join(self.thinking_buffer)
|
full_thinking = "".join(self.thinking_buffer)
|
||||||
log.write(_dim(f"· thinking: {full_thinking}"))
|
debug_log.write(_dim(f"· thinking: {full_thinking}"))
|
||||||
self.thinking_buffer.clear()
|
self.thinking_buffer.clear()
|
||||||
self.thinking_open = False
|
self.thinking_open = False
|
||||||
thinking_widget.update("")
|
thinking_widget.update("")
|
||||||
@@ -243,36 +251,46 @@ class TuiPresenterState:
|
|||||||
log.write(event.content)
|
log.write(event.content)
|
||||||
return
|
return
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
# Terminal events: load-bearing label (no demotion).
|
# Terminal events: load-bearing label tinted per outcome.
|
||||||
|
# v0.5.1 polish: Aurora green / Dawn red / Dawn yellow so the
|
||||||
|
# turn-terminal status is scannable at a glance vs blending
|
||||||
|
# with default foreground.
|
||||||
if isinstance(event, Done):
|
if isinstance(event, Done):
|
||||||
log.write(
|
log.write(RichText(
|
||||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
||||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||||
f"usage {_format_usage(event.usage, arrow='→')}"
|
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||||
)
|
style=_AU_SUCCESS,
|
||||||
|
))
|
||||||
if not raw:
|
if not raw:
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
|
|
||||||
log.write(Rule())
|
# Rule tinted to match the column border so the
|
||||||
|
# streamed-text / markdown-render boundary reads as
|
||||||
|
# part of the chrome family, not a content artifact.
|
||||||
|
log.write(Rule(style=_AU_DEMOTED))
|
||||||
log.write(Markdown(event.response))
|
log.write(Markdown(event.response))
|
||||||
elif isinstance(event, Error):
|
elif isinstance(event, Error):
|
||||||
log.write(
|
log.write(RichText(
|
||||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||||
f"message={event.message!r}"
|
f"message={event.message!r}",
|
||||||
)
|
style=_AU_ERROR,
|
||||||
|
))
|
||||||
else: # Cancelled
|
else: # Cancelled
|
||||||
log.write(
|
log.write(RichText(
|
||||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||||
f"partial_message_id={event.partial_message_id}"
|
f"partial_message_id={event.partial_message_id}",
|
||||||
)
|
style=_AU_WARNING,
|
||||||
|
))
|
||||||
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
|
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
|
||||||
# terminal event, even if thinking_open was False — per STEPS 5-6.
|
# terminal event, even if thinking_open was False — per STEPS 5-6.
|
||||||
thinking_widget.update("")
|
thinking_widget.update("")
|
||||||
thinking_widget.display = False
|
thinking_widget.display = False
|
||||||
return
|
return
|
||||||
if isinstance(event, WorkerPhase):
|
if isinstance(event, WorkerPhase):
|
||||||
log.write(_dim(
|
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||||
|
debug_log.write(_dim(
|
||||||
f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
|
f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -290,7 +308,8 @@ class TuiPresenterState:
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
if isinstance(event, TextBoundary):
|
if isinstance(event, TextBoundary):
|
||||||
log.write(_dim(
|
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||||
|
debug_log.write(_dim(
|
||||||
f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
|
f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -299,10 +318,17 @@ class TuiPresenterState:
|
|||||||
# the original event AND a render_error line with the class name only
|
# the original event AND a render_error line with the class name only
|
||||||
# (NO exception message — security clause). Volva F1 fix.
|
# (NO exception message — security clause). Volva F1 fix.
|
||||||
#
|
#
|
||||||
# Issue #13: routing-under-failure preservation — ToolStart/ToolResult
|
# v0.5.0: routing-under-failure preservation — fallback writes go
|
||||||
# fallback writes go to tools_log (the routed destination per
|
# to the same destination the successful render would have used:
|
||||||
# INV-014), not the transcript. Every other event falls back to log.
|
# - ToolStart/ToolResult → tools_log
|
||||||
target = tools_log if isinstance(event, (ToolStart, ToolResult)) else log
|
# - WorkerPhase/Thinking/TextBoundary → debug_log
|
||||||
|
# - everything else (Text/Done/Error/Cancelled) → log
|
||||||
|
if isinstance(event, (ToolStart, ToolResult)):
|
||||||
|
target = tools_log
|
||||||
|
elif isinstance(event, (WorkerPhase, Thinking, TextBoundary)):
|
||||||
|
target = debug_log
|
||||||
|
else:
|
||||||
|
target = log
|
||||||
target.write(_plain_label(event))
|
target.write(_plain_label(event))
|
||||||
target.write(f"[render_error] {type(exc).__name__}")
|
target.write(f"[render_error] {type(exc).__name__}")
|
||||||
|
|
||||||
@@ -380,20 +406,32 @@ 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.4.1: Australis theme — Aurora blue/cyan primary, Ice background,
|
# v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface
|
||||||
# Sea darks for chrome separators. Widget colors use theme variables
|
# (Sea bright-black #373b46) overriding Textual's default $primary-blue
|
||||||
# ($primary/$accent/$au-dark-60/$au-bright-cyan/etc.) so a future theme
|
# tinting. TabbedContent active-tab tinting also softened.
|
||||||
# swap rebinds them centrally.
|
#
|
||||||
|
# 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;
|
||||||
}
|
}
|
||||||
@@ -408,13 +446,26 @@ class RatatoskrApp(App[int]):
|
|||||||
dock: top;
|
dock: top;
|
||||||
height: auto;
|
height: auto;
|
||||||
color: $au-dark-60;
|
color: $au-dark-60;
|
||||||
|
padding: 0 1;
|
||||||
|
text-style: italic;
|
||||||
}
|
}
|
||||||
#transcript {
|
#transcript {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: $background;
|
background: $background;
|
||||||
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
#tools-log {
|
#tools-log, #debug-log {
|
||||||
background: $background;
|
background: $background;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
#side-panes Tabs {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
/* Active tab: Aurora bright-cyan label so the operator's eye lands
|
||||||
|
on the currently selected pane name. */
|
||||||
|
#side-panes Tab.-active {
|
||||||
|
color: $au-bright-cyan;
|
||||||
|
text-style: bold;
|
||||||
}
|
}
|
||||||
#prompt {
|
#prompt {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
@@ -423,6 +474,10 @@ class RatatoskrApp(App[int]):
|
|||||||
#prompt:focus {
|
#prompt:focus {
|
||||||
border: tall $primary;
|
border: tall $primary;
|
||||||
}
|
}
|
||||||
|
/* Placeholder text in the Input — dimmer than typed content. */
|
||||||
|
#prompt > .input--placeholder {
|
||||||
|
color: $au-dark-50;
|
||||||
|
}
|
||||||
#identity {
|
#identity {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
height: 1;
|
height: 1;
|
||||||
@@ -446,10 +501,10 @@ class RatatoskrApp(App[int]):
|
|||||||
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"
|
||||||
@@ -478,9 +533,9 @@ class RatatoskrApp(App[int]):
|
|||||||
|
|
||||||
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
|
||||||
@@ -488,17 +543,21 @@ class RatatoskrApp(App[int]):
|
|||||||
# widget-level markup=True.
|
# widget-level markup=True.
|
||||||
with Horizontal(id="main-row"):
|
with Horizontal(id="main-row"):
|
||||||
with Vertical(id="left-column"):
|
with Vertical(id="left-column"):
|
||||||
yield Static("", id="thinking-current")
|
|
||||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||||
with Vertical(id="right-column"):
|
with Vertical(id="right-column"):
|
||||||
|
yield Static("", id="thinking-current")
|
||||||
with TabbedContent(id="side-panes"):
|
with TabbedContent(id="side-panes"):
|
||||||
with TabPane("Tools", id="tools-tab"):
|
with TabPane("Tools", id="tools-tab"):
|
||||||
yield RichLog(
|
yield RichLog(
|
||||||
id="tools-log", wrap=True, markup=False, highlight=False
|
id="tools-log", wrap=True, markup=False, highlight=False
|
||||||
)
|
)
|
||||||
|
with TabPane("Debug", id="debug-tab"):
|
||||||
|
yield RichLog(
|
||||||
|
id="debug-log", wrap=True, markup=False, highlight=False
|
||||||
|
)
|
||||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||||
# INV (issue #13): pane-name widget displays current side-pane name.
|
# pane-name widget displays current side-pane name.
|
||||||
yield Static("", id="identity")
|
yield Static("", id="identity")
|
||||||
yield Static("Tools", id="pane-name")
|
yield Static("Tools", id="pane-name")
|
||||||
yield Static(self.HINT_IDLE, id="hint")
|
yield Static(self.HINT_IDLE, id="hint")
|
||||||
@@ -517,6 +576,21 @@ class RatatoskrApp(App[int]):
|
|||||||
self.query_one("#identity", Static).update(identity)
|
self.query_one("#identity", Static).update(identity)
|
||||||
# Issue #12: thinking widget hidden until a thinking event fires.
|
# Issue #12: thinking widget hidden until a thinking event fires.
|
||||||
self.query_one("#thinking-current", Static).display = False
|
self.query_one("#thinking-current", Static).display = False
|
||||||
|
# v0.5.1 polish: empty-state placeholder lines so the operator sees
|
||||||
|
# the pane is intentionally empty (not broken) before any turn fires.
|
||||||
|
# Wrapped in Australis dark-50 italic so they read distinctly as
|
||||||
|
# placeholder text, not real telemetry. Disappear naturally as the
|
||||||
|
# log fills with real events (the placeholders scroll off the top).
|
||||||
|
from rich.text import Text as RichText
|
||||||
|
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
|
||||||
|
self.query_one("#tools-log", RichLog).write(
|
||||||
|
RichText("(no tool events yet — start a turn that uses tools)",
|
||||||
|
style=placeholder_style)
|
||||||
|
)
|
||||||
|
self.query_one("#debug-log", RichLog).write(
|
||||||
|
RichText("(waiting for telemetry — start a turn)",
|
||||||
|
style=placeholder_style)
|
||||||
|
)
|
||||||
self.state = "idle"
|
self.state = "idle"
|
||||||
self._set_hint(self.HINT_IDLE)
|
self._set_hint(self.HINT_IDLE)
|
||||||
|
|
||||||
@@ -559,8 +633,9 @@ class RatatoskrApp(App[int]):
|
|||||||
assert content
|
assert content
|
||||||
log = self.query_one("#transcript", RichLog)
|
log = self.query_one("#transcript", RichLog)
|
||||||
thinking_widget = self.query_one("#thinking-current", Static)
|
thinking_widget = self.query_one("#thinking-current", Static)
|
||||||
# Issue #13: tools_log routes ToolStart/ToolResult into the Tools pane.
|
# v0.5.0: separate panes for tools vs telemetry; transcript is content only.
|
||||||
tools_log = self.query_one("#tools-log", RichLog)
|
tools_log = self.query_one("#tools-log", RichLog)
|
||||||
|
debug_log = self.query_one("#debug-log", RichLog)
|
||||||
presenter = TuiPresenterState()
|
presenter = TuiPresenterState()
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn(self.client, self.session_id, content):
|
async for event in stream_turn(self.client, self.session_id, content):
|
||||||
@@ -571,6 +646,7 @@ class RatatoskrApp(App[int]):
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=thinking_widget,
|
thinking_widget=thinking_widget,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
|
debug_log=debug_log,
|
||||||
raw=self.args.raw,
|
raw=self.args.raw,
|
||||||
)
|
)
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
@@ -631,6 +707,12 @@ class RatatoskrApp(App[int]):
|
|||||||
— `test_ctrl_1_preserves_input_focus` is the regression guard.
|
— `test_ctrl_1_preserves_input_focus` is the regression guard.
|
||||||
"""
|
"""
|
||||||
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
|
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
|
||||||
|
self.query_one("#pane-name", Static).update("Tools")
|
||||||
|
|
||||||
|
def action_focus_debug(self) -> None:
|
||||||
|
"""v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus."""
|
||||||
|
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
|
||||||
|
self.query_one("#pane-name", Static).update("Debug")
|
||||||
|
|
||||||
|
|
||||||
def run_tui(args: ParsedArgs) -> int:
|
def run_tui(args: ParsedArgs) -> int:
|
||||||
|
|||||||
+228
-53
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
|
|||||||
|
|
||||||
|
|
||||||
def _spy_writes(monkeypatch) -> list:
|
def _spy_writes(monkeypatch) -> list:
|
||||||
"""Patch RichLog.write to record every arg into a list (returned)."""
|
"""Patch RichLog.write to record every arg into a list (returned).
|
||||||
|
|
||||||
|
Accepts *args/**kwargs so Textual's internal deferred-render path
|
||||||
|
(which calls write positionally with width/expand/shrink/scroll_end)
|
||||||
|
still works after a write-during-mount + Resize sequence.
|
||||||
|
"""
|
||||||
writes: list = []
|
writes: list = []
|
||||||
original = RichLog.write
|
original = RichLog.write
|
||||||
|
|
||||||
def spy(self, content, **kw):
|
def spy(self, content, *args, **kw):
|
||||||
writes.append(content)
|
writes.append(content)
|
||||||
return original(self, content, **kw)
|
return original(self, content, *args, **kw)
|
||||||
|
|
||||||
monkeypatch.setattr(RichLog, "write", spy)
|
monkeypatch.setattr(RichLog, "write", spy)
|
||||||
return writes
|
return writes
|
||||||
@@ -126,6 +131,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -133,6 +139,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -140,24 +147,28 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Widget updated 3 times — once per delta — with cumulative content
|
# Widget updated 3 times — once per delta — with cumulative content
|
||||||
assert widget.update.call_count == 3
|
assert widget.update.call_count == 3
|
||||||
# Latest call shows the full accumulated content (under 200 chars so no truncation)
|
# Latest call shows the full accumulated content (under 200 chars so no truncation).
|
||||||
assert widget.update.call_args_list[-1][0][0] == "abc"
|
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
|
||||||
|
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
|
||||||
# Widget became visible at first delta
|
# Widget became visible at first delta
|
||||||
assert widget.display is True
|
assert widget.display is True
|
||||||
# No RichLog write yet — closure hasn't fired
|
# No RichLog write yet — closure hasn't fired
|
||||||
assert log.write.call_count == 0
|
assert log.write.call_count == 0
|
||||||
|
|
||||||
def test_thinking_closes_one_richlog_entry(self) -> None:
|
def test_thinking_closes_one_debuglog_entry(self) -> None:
|
||||||
"""thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase →
|
"""thinking_closes_one_debuglog_entry [happy, v0.5.0]: 2x Thinking + WorkerPhase →
|
||||||
RichLog has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden.
|
debug_log has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden;
|
||||||
|
transcript (log) untouched.
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
|
debug_log = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
@@ -165,6 +176,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -172,6 +184,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -179,14 +192,16 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
|
# v0.5.0: closure + worker_phase write to debug_log; transcript untouched.
|
||||||
assert log.write.call_count == 2
|
assert debug_log.write.call_count == 2
|
||||||
|
assert not log.write.called
|
||||||
# First write = closed thinking entry containing the full accumulated text
|
# First write = closed thinking entry containing the full accumulated text
|
||||||
assert "· thinking: ab" in log.write.call_args_list[0][0][0]
|
assert "· thinking: ab" in _text_of(debug_log.write.call_args_list[0][0][0])
|
||||||
# Second write = worker_phase with demotion prefix
|
# Second write = worker_phase with demotion prefix (also in debug_log)
|
||||||
assert "· worker_phase:" in log.write.call_args_list[1][0][0]
|
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[1][0][0])
|
||||||
# Widget cleared + hidden
|
# Widget cleared + hidden
|
||||||
widget.update.assert_called_with("")
|
widget.update.assert_called_with("")
|
||||||
assert widget.display is False
|
assert widget.display is False
|
||||||
@@ -205,12 +220,15 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
last_update = widget.update.call_args_list[-1][0][0]
|
last_update = widget.update.call_args_list[-1][0][0]
|
||||||
# …-prefix + last-200 = 201 chars
|
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||||||
assert last_update.startswith("…")
|
assert last_update.startswith("thinking… ")
|
||||||
assert len(last_update) == 201
|
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
|
||||||
|
assert len(last_update) == len("thinking… ") + 201
|
||||||
|
assert "…" in last_update
|
||||||
|
|
||||||
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
||||||
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
||||||
@@ -228,6 +246,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
assert widget.display is True
|
assert widget.display is True
|
||||||
@@ -237,17 +256,20 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
assert widget.display is False
|
assert widget.display is False
|
||||||
|
|
||||||
def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None:
|
def test_multiple_thinking_runs_each_get_debuglog_entry(self) -> None:
|
||||||
"""multiple_thinking_runs_each_get_richlog_entry [scenario]:
|
"""multiple_thinking_runs_each_get_debuglog_entry [scenario, v0.5.0]:
|
||||||
Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries.
|
Thinking → Text → Thinking → Done → TWO closed thinking entries in debug_log
|
||||||
|
(transcript receives only the Text + Done content).
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
|
debug_log = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
@@ -255,6 +277,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -262,6 +285,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -269,46 +293,62 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Close the second run with a Done.
|
# Close the second run with a Done.
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
|
_make_tui_done(),
|
||||||
|
log=log,
|
||||||
|
thinking_widget=widget,
|
||||||
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
|
raw=True,
|
||||||
)
|
)
|
||||||
# Count closed thinking entries — now dim RichText; plain text starts with "· thinking:".
|
# v0.5.0: closed thinking entries land in debug_log, NOT log.
|
||||||
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list]
|
thinking_entries = [_text_of(call[0][0]) for call in debug_log.write.call_args_list]
|
||||||
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
|
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
|
||||||
assert len(thinking_entries) == 2
|
assert len(thinking_entries) == 2
|
||||||
assert "first" in thinking_entries[0]
|
assert "first" in thinking_entries[0]
|
||||||
assert "second" in thinking_entries[1]
|
assert "second" in thinking_entries[1]
|
||||||
|
# transcript receives: "hi" (Text) + "[done] ..." (terminal label) only.
|
||||||
|
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||||
|
assert "hi" in log_writes
|
||||||
|
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||||
|
|
||||||
def test_render_exception_fallback(self) -> None:
|
def test_render_exception_fallback(self) -> None:
|
||||||
"""render_exception_fallback [adversarial]:
|
"""render_exception_fallback [adversarial, v0.5.0]:
|
||||||
widget.update raises → RichLog gets BOTH a plain-labeled fallback line for
|
widget.update raises → debug_log gets BOTH a plain-labeled fallback line
|
||||||
the original event AND a `[render_error] <ExceptionClassName>` line
|
for the original Thinking event AND a `[render_error] <ExceptionClassName>`
|
||||||
(NO exception message per INV-009 security clause); state does NOT propagate.
|
line (NO exception message per INV-009 security clause). transcript
|
||||||
|
receives nothing — routing preservation under failure (debug-shaped
|
||||||
|
event falls back to debug_log).
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
|
debug_log = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
# Should not raise; should write a fallback labeled line + a [render_error] line.
|
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content="x"),
|
Thinking(sse_id=SID, content="x"),
|
||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)]
|
writes = [c[0][0] for c in debug_log.write.call_args_list if isinstance(c[0][0], str)]
|
||||||
# POST-007: plain-label fallback for the original Thinking event (pre-amendment shape).
|
# POST-007: plain-label fallback for the original Thinking event.
|
||||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
assert any(w.startswith("[thinking]") for w in writes), writes
|
||||||
# POST-007: render_error line with class name ONLY.
|
# POST-007: render_error line with class name ONLY.
|
||||||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||||||
# Critical: exception message MUST NOT appear in any write (INV-009 security).
|
# Critical: exception message MUST NOT appear in any write (INV-009 security).
|
||||||
assert not any("widget gone" in w for w in writes), writes
|
assert not any("widget gone" in w for w in writes), writes
|
||||||
|
# v0.5.0 routing preservation: transcript receives NOTHING on a
|
||||||
|
# debug-shaped event's failure path.
|
||||||
|
assert not log.write.called
|
||||||
|
|
||||||
def test_state_reset_per_worker(self) -> None:
|
def test_state_reset_per_worker(self) -> None:
|
||||||
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
||||||
@@ -320,6 +360,7 @@ class TestTuiPresenterState:
|
|||||||
log=MagicMock(),
|
log=MagicMock(),
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
s2 = TuiPresenterState()
|
s2 = TuiPresenterState()
|
||||||
@@ -327,12 +368,14 @@ class TestTuiPresenterState:
|
|||||||
assert s2.thinking_open is False
|
assert s2.thinking_open is False
|
||||||
|
|
||||||
def test_cancelled_mid_thinking_closes(self) -> None:
|
def test_cancelled_mid_thinking_closes(self) -> None:
|
||||||
"""cancelled_mid_thinking_closes [scenario]:
|
"""cancelled_mid_thinking_closes [scenario, v0.5.0]:
|
||||||
Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden.
|
Thinking, Cancelled → ONE closed thinking entry in debug_log + a
|
||||||
|
[cancelled] entry in transcript; widget hidden.
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
|
debug_log = MagicMock()
|
||||||
widget = MagicMock()
|
widget = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
@@ -340,6 +383,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
@@ -349,12 +393,14 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Closed thinking entries are now dim RichText; terminal labels are plain str.
|
# v0.5.0: closed thinking entry lands in debug_log; terminal [cancelled] in transcript.
|
||||||
writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
debug_writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
|
||||||
assert any(w.startswith("· thinking: partial") for w in writes)
|
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||||
assert any(w.startswith("[cancelled]") for w in writes)
|
assert any(w.startswith("· thinking: partial") for w in debug_writes)
|
||||||
|
assert any(w.startswith("[cancelled]") for w in log_writes)
|
||||||
assert widget.display is False
|
assert widget.display is False
|
||||||
|
|
||||||
def test_done_renders_markdown_after_label(self) -> None:
|
def test_done_renders_markdown_after_label(self) -> None:
|
||||||
@@ -374,16 +420,22 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=widget,
|
thinking_widget=widget,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False
|
_make_tui_done(),
|
||||||
|
log=log,
|
||||||
|
thinking_widget=widget,
|
||||||
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
|
raw=False,
|
||||||
)
|
)
|
||||||
writes = [c[0][0] for c in log.write.call_args_list]
|
writes = [c[0][0] for c in log.write.call_args_list]
|
||||||
# Text stream wrote "hi" with no prefix.
|
# Text stream wrote "hi" with no prefix.
|
||||||
assert "hi" in writes
|
assert "hi" in writes
|
||||||
# [done] label wrote.
|
# v0.5.1: [done] label is now RichText (Aurora green); plain content test via _text_of.
|
||||||
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
|
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||||||
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
|
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
|
||||||
assert any(isinstance(w, Rule) for w in writes)
|
assert any(isinstance(w, Rule) for w in writes)
|
||||||
assert any(isinstance(w, Markdown) for w in writes)
|
assert any(isinstance(w, Markdown) for w in writes)
|
||||||
@@ -403,33 +455,44 @@ 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.
|
||||||
|
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.
|
# 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
|
# 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.
|
# 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
|
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__
|
||||||
@@ -480,6 +550,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# INV-014: write went to tools_log
|
# INV-014: write went to tools_log
|
||||||
@@ -500,6 +571,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
assert tools_log.write.called
|
assert tools_log.write.called
|
||||||
@@ -518,6 +590,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
assert log.write.called
|
assert log.write.called
|
||||||
@@ -536,6 +609,7 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
line = log.write.call_args[0][0]
|
line = log.write.call_args[0][0]
|
||||||
@@ -553,12 +627,13 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=True,
|
raw=True,
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
c[0][0]
|
_text_of(c[0][0])
|
||||||
for c in log.write.call_args_list
|
for c in log.write.call_args_list
|
||||||
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
|
if _text_of(c[0][0]).startswith("[done]")
|
||||||
)
|
)
|
||||||
assert "duration=5.5s" in done_line
|
assert "duration=5.5s" in done_line
|
||||||
assert "duration_ms=5467" not in done_line
|
assert "duration_ms=5467" not in done_line
|
||||||
@@ -580,12 +655,13 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
thinking_widget=MagicMock(),
|
thinking_widget=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
raw=True,
|
raw=True,
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
c[0][0]
|
_text_of(c[0][0])
|
||||||
for c in log.write.call_args_list
|
for c in log.write.call_args_list
|
||||||
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
|
if _text_of(c[0][0]).startswith("[done]")
|
||||||
)
|
)
|
||||||
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
||||||
|
|
||||||
@@ -738,8 +814,12 @@ class TestLayoutShape:
|
|||||||
row = app.query_one("#main-row", Horizontal)
|
row = app.query_one("#main-row", Horizontal)
|
||||||
assert row is not None
|
assert row is not None
|
||||||
|
|
||||||
async def test_left_column_has_transcript_and_prompt(self) -> None:
|
async def test_left_column_content_only(self) -> None:
|
||||||
"""left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking."""
|
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
|
||||||
|
|
||||||
|
thinking-current Static moved to right column so the left column is
|
||||||
|
genuinely content-only (transcript + prompt input).
|
||||||
|
"""
|
||||||
from textual.containers import Vertical
|
from textual.containers import Vertical
|
||||||
from textual.widgets import Input, RichLog, Static
|
from textual.widgets import Input, RichLog, Static
|
||||||
|
|
||||||
@@ -747,14 +827,15 @@ class TestLayoutShape:
|
|||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
left = app.query_one("#left-column", Vertical)
|
left = app.query_one("#left-column", Vertical)
|
||||||
assert left is not None
|
right = app.query_one("#right-column", Vertical)
|
||||||
transcript = app.query_one("#transcript", RichLog)
|
transcript = app.query_one("#transcript", RichLog)
|
||||||
prompt = app.query_one("#prompt", Input)
|
prompt = app.query_one("#prompt", Input)
|
||||||
thinking = app.query_one("#thinking-current", Static)
|
thinking = app.query_one("#thinking-current", Static)
|
||||||
# Widgets are inside the left column (descendant check)
|
|
||||||
assert transcript in left.walk_children()
|
assert transcript in left.walk_children()
|
||||||
assert prompt in left.walk_children()
|
assert prompt in left.walk_children()
|
||||||
assert thinking in left.walk_children()
|
# v0.5.0: thinking-current is now under the right column, NOT left.
|
||||||
|
assert thinking not in left.walk_children()
|
||||||
|
assert thinking in right.walk_children()
|
||||||
|
|
||||||
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
|
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
|
||||||
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
|
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
|
||||||
@@ -819,6 +900,100 @@ class TestLayoutShape:
|
|||||||
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
|
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_debug_tab_exists(self) -> None:
|
||||||
|
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog."""
|
||||||
|
from textual.widgets import RichLog, TabPane
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
debug_tab = app.query_one("#debug-tab", TabPane)
|
||||||
|
debug_log = app.query_one("#debug-log", RichLog)
|
||||||
|
assert debug_log in debug_tab.walk_children()
|
||||||
|
|
||||||
|
async def test_ctrl_2_activates_debug_tab(self) -> None:
|
||||||
|
"""ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'."""
|
||||||
|
from textual.widgets import TabbedContent
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("ctrl+2")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
||||||
|
|
||||||
|
async def test_done_label_styled_success(self) -> None:
|
||||||
|
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
|
||||||
|
from rich.text import Text as RichText
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
# Probe the presenter directly — write a Done via state.render.
|
||||||
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
log = app.query_one("#transcript", RichLog)
|
||||||
|
state = TuiPresenterState()
|
||||||
|
seen: list = []
|
||||||
|
orig = log.write
|
||||||
|
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
|
||||||
|
state.render(
|
||||||
|
_make_tui_done(),
|
||||||
|
log=log,
|
||||||
|
thinking_widget=app.query_one("#thinking-current"),
|
||||||
|
tools_log=app.query_one("#tools-log", RichLog),
|
||||||
|
debug_log=app.query_one("#debug-log", RichLog),
|
||||||
|
raw=True,
|
||||||
|
)
|
||||||
|
done = next(
|
||||||
|
c for c in seen
|
||||||
|
if isinstance(c, RichText) and _text_of(c).startswith("[done]")
|
||||||
|
)
|
||||||
|
assert done.style == "#16B866" # Aurora green
|
||||||
|
|
||||||
|
async def test_empty_state_placeholders_present(self) -> None:
|
||||||
|
"""empty_state_placeholders_present [v0.5.1]: tools-log + debug-log show
|
||||||
|
placeholder lines before any turn fires."""
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
# Activate Debug tab so its content actually renders.
|
||||||
|
from textual.widgets import TabbedContent
|
||||||
|
tabbed = app.query_one("#side-panes", TabbedContent)
|
||||||
|
tabbed.active = "debug-tab"
|
||||||
|
await pilot.pause()
|
||||||
|
tabbed.active = "tools-tab"
|
||||||
|
await pilot.pause()
|
||||||
|
tools_log = app.query_one("#tools-log", RichLog)
|
||||||
|
debug_log = app.query_one("#debug-log", RichLog)
|
||||||
|
tools_text = " ".join(str(line) for line in tools_log.lines)
|
||||||
|
tabbed.active = "debug-tab"
|
||||||
|
await pilot.pause()
|
||||||
|
debug_text = " ".join(str(line) for line in debug_log.lines)
|
||||||
|
assert "no tool events" in tools_text
|
||||||
|
assert "waiting for telemetry" in debug_text
|
||||||
|
|
||||||
|
async def test_pane_name_updates_on_tab_switch(self) -> None:
|
||||||
|
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
|
||||||
|
|
||||||
|
Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug"
|
||||||
|
and back as the operator switches via Ctrl+1 / Ctrl+2.
|
||||||
|
"""
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane_name = app.query_one("#pane-name", Static)
|
||||||
|
await pilot.press("ctrl+2")
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(pane_name.render()) == "Debug"
|
||||||
|
await pilot.press("ctrl+1")
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(pane_name.render()) == "Tools"
|
||||||
|
|
||||||
|
|
||||||
import asyncio # noqa: E402
|
import asyncio # noqa: E402
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user