Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7106af5c09 |
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-24 (post-v0.5.0 content-only main pane + Debug tab):_
|
||||
_As of 2026-05-24 (post-v0.5.1 UI polish pass):_
|
||||
|
||||
**Status: v0.5.0 shipped.** Nine core issues complete (`sse_client`
|
||||
**Status: v0.5.1 shipped.** Nine core issues complete (`sse_client`
|
||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||
startup error visibility #6, presenter contract semantics amendment
|
||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||
@@ -51,7 +51,8 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
|
||||
land). CLI mode (--send) unaffected by design — INV-018.
|
||||
|
||||
Last commits on `main`:
|
||||
- v0.5.0 refactor(tui): content-only main pane + Debug tab + chrome dark
|
||||
- v0.5.1 style(tui): polish pass — colored terminal labels, placeholders, padding
|
||||
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
|
||||
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
|
||||
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
|
||||
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+55
-13
@@ -118,6 +118,7 @@ _AU_ERROR = "#ff491a"
|
||||
_AU_WARNING = "#e1c631"
|
||||
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
|
||||
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
|
||||
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
|
||||
|
||||
|
||||
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
||||
@@ -227,8 +228,12 @@ class TuiPresenterState:
|
||||
self.thinking_open = True
|
||||
self.thinking_buffer.append(event.content)
|
||||
acc = "".join(self.thinking_buffer)
|
||||
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(display_text)
|
||||
# v0.5.1 polish: prefix the live widget with "thinking… " so
|
||||
# operators recognize what the streaming content is (otherwise
|
||||
# the static-content under Header reads like uncontextualized
|
||||
# spillover). Truncate display to last 200 chars + ellipsis.
|
||||
tail = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(f"thinking… {tail}")
|
||||
return
|
||||
# Non-thinking event: close any open thinking run.
|
||||
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
|
||||
@@ -246,29 +251,38 @@ class TuiPresenterState:
|
||||
log.write(event.content)
|
||||
return
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
# Terminal events: load-bearing label (no demotion).
|
||||
# Terminal events: load-bearing label tinted per outcome.
|
||||
# v0.5.1 polish: Aurora green / Dawn red / Dawn yellow so the
|
||||
# turn-terminal status is scannable at a glance vs blending
|
||||
# with default foreground.
|
||||
if isinstance(event, Done):
|
||||
log.write(
|
||||
log.write(RichText(
|
||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||
f"usage {_format_usage(event.usage, arrow='→')}"
|
||||
)
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
))
|
||||
if not raw:
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
log.write(Rule())
|
||||
# Rule tinted to match the column border so the
|
||||
# streamed-text / markdown-render boundary reads as
|
||||
# part of the chrome family, not a content artifact.
|
||||
log.write(Rule(style=_AU_DEMOTED))
|
||||
log.write(Markdown(event.response))
|
||||
elif isinstance(event, Error):
|
||||
log.write(
|
||||
log.write(RichText(
|
||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||
f"message={event.message!r}"
|
||||
)
|
||||
f"message={event.message!r}",
|
||||
style=_AU_ERROR,
|
||||
))
|
||||
else: # Cancelled
|
||||
log.write(
|
||||
log.write(RichText(
|
||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||
f"partial_message_id={event.partial_message_id}"
|
||||
)
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
))
|
||||
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
|
||||
# terminal event, even if thinking_open was False — per STEPS 5-6.
|
||||
thinking_widget.update("")
|
||||
@@ -433,17 +447,26 @@ class RatatoskrApp(App[int]):
|
||||
height: auto;
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
text-style: italic;
|
||||
}
|
||||
#transcript {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
#tools-log, #debug-log {
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
#side-panes Tabs {
|
||||
background: $surface;
|
||||
}
|
||||
/* Active tab: Aurora bright-cyan label so the operator's eye lands
|
||||
on the currently selected pane name. */
|
||||
#side-panes Tab.-active {
|
||||
color: $au-bright-cyan;
|
||||
text-style: bold;
|
||||
}
|
||||
#prompt {
|
||||
dock: bottom;
|
||||
border: tall $panel;
|
||||
@@ -451,6 +474,10 @@ class RatatoskrApp(App[int]):
|
||||
#prompt:focus {
|
||||
border: tall $primary;
|
||||
}
|
||||
/* Placeholder text in the Input — dimmer than typed content. */
|
||||
#prompt > .input--placeholder {
|
||||
color: $au-dark-50;
|
||||
}
|
||||
#identity {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
@@ -549,6 +576,21 @@ class RatatoskrApp(App[int]):
|
||||
self.query_one("#identity", Static).update(identity)
|
||||
# Issue #12: thinking widget hidden until a thinking event fires.
|
||||
self.query_one("#thinking-current", Static).display = False
|
||||
# v0.5.1 polish: empty-state placeholder lines so the operator sees
|
||||
# the pane is intentionally empty (not broken) before any turn fires.
|
||||
# Wrapped in Australis dark-50 italic so they read distinctly as
|
||||
# placeholder text, not real telemetry. Disappear naturally as the
|
||||
# log fills with real events (the placeholders scroll off the top).
|
||||
from rich.text import Text as RichText
|
||||
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
|
||||
self.query_one("#tools-log", RichLog).write(
|
||||
RichText("(no tool events yet — start a turn that uses tools)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.query_one("#debug-log", RichLog).write(
|
||||
RichText("(waiting for telemetry — start a turn)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.state = "idle"
|
||||
self._set_hint(self.HINT_IDLE)
|
||||
|
||||
|
||||
+75
-14
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
|
||||
|
||||
|
||||
def _spy_writes(monkeypatch) -> list:
|
||||
"""Patch RichLog.write to record every arg into a list (returned)."""
|
||||
"""Patch RichLog.write to record every arg into a list (returned).
|
||||
|
||||
Accepts *args/**kwargs so Textual's internal deferred-render path
|
||||
(which calls write positionally with width/expand/shrink/scroll_end)
|
||||
still works after a write-during-mount + Resize sequence.
|
||||
"""
|
||||
writes: list = []
|
||||
original = RichLog.write
|
||||
|
||||
def spy(self, content, **kw):
|
||||
def spy(self, content, *args, **kw):
|
||||
writes.append(content)
|
||||
return original(self, content, **kw)
|
||||
return original(self, content, *args, **kw)
|
||||
|
||||
monkeypatch.setattr(RichLog, "write", spy)
|
||||
return writes
|
||||
@@ -147,8 +152,9 @@ class TestTuiPresenterState:
|
||||
)
|
||||
# Widget updated 3 times — once per delta — with cumulative content
|
||||
assert widget.update.call_count == 3
|
||||
# Latest call shows the full accumulated content (under 200 chars so no truncation)
|
||||
assert widget.update.call_args_list[-1][0][0] == "abc"
|
||||
# Latest call shows the full accumulated content (under 200 chars so no truncation).
|
||||
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
|
||||
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
|
||||
# Widget became visible at first delta
|
||||
assert widget.display is True
|
||||
# No RichLog write yet — closure hasn't fired
|
||||
@@ -218,9 +224,11 @@ class TestTuiPresenterState:
|
||||
raw=False,
|
||||
)
|
||||
last_update = widget.update.call_args_list[-1][0][0]
|
||||
# …-prefix + last-200 = 201 chars
|
||||
assert last_update.startswith("…")
|
||||
assert len(last_update) == 201
|
||||
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||||
assert last_update.startswith("thinking… ")
|
||||
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
|
||||
assert len(last_update) == len("thinking… ") + 201
|
||||
assert "…" in last_update
|
||||
|
||||
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
||||
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
||||
@@ -426,8 +434,8 @@ class TestTuiPresenterState:
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
# Text stream wrote "hi" with no prefix.
|
||||
assert "hi" in writes
|
||||
# [done] label wrote.
|
||||
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
|
||||
# v0.5.1: [done] label is now RichText (Aurora green); plain content test via _text_of.
|
||||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||||
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
@@ -623,9 +631,9 @@ class TestTuiPresenterState:
|
||||
raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
c[0][0]
|
||||
_text_of(c[0][0])
|
||||
for c in log.write.call_args_list
|
||||
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
|
||||
if _text_of(c[0][0]).startswith("[done]")
|
||||
)
|
||||
assert "duration=5.5s" in done_line
|
||||
assert "duration_ms=5467" not in done_line
|
||||
@@ -651,9 +659,9 @@ class TestTuiPresenterState:
|
||||
raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
c[0][0]
|
||||
_text_of(c[0][0])
|
||||
for c in log.write.call_args_list
|
||||
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
|
||||
if _text_of(c[0][0]).startswith("[done]")
|
||||
)
|
||||
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
||||
|
||||
@@ -914,6 +922,59 @@ class TestLayoutShape:
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user