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
|
## 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
|
#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,8 @@ 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.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)
|
- `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)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+55
-13
@@ -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 -------------------------
|
||||||
@@ -227,8 +228,12 @@ 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.
|
# Non-thinking event: close any open thinking run.
|
||||||
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
|
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
|
||||||
@@ -246,29 +251,38 @@ 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("")
|
||||||
@@ -433,17 +447,26 @@ class RatatoskrApp(App[int]):
|
|||||||
height: auto;
|
height: auto;
|
||||||
color: $au-dark-60;
|
color: $au-dark-60;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
|
text-style: italic;
|
||||||
}
|
}
|
||||||
#transcript {
|
#transcript {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: $background;
|
background: $background;
|
||||||
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
#tools-log, #debug-log {
|
#tools-log, #debug-log {
|
||||||
background: $background;
|
background: $background;
|
||||||
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
#side-panes Tabs {
|
#side-panes Tabs {
|
||||||
background: $surface;
|
background: $surface;
|
||||||
}
|
}
|
||||||
|
/* Active tab: Aurora bright-cyan label so the operator's eye lands
|
||||||
|
on the currently selected pane name. */
|
||||||
|
#side-panes Tab.-active {
|
||||||
|
color: $au-bright-cyan;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
#prompt {
|
#prompt {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
border: tall $panel;
|
border: tall $panel;
|
||||||
@@ -451,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;
|
||||||
@@ -549,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)
|
||||||
|
|
||||||
|
|||||||
+75
-14
@@ -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
|
||||||
@@ -147,8 +152,9 @@ class TestTuiPresenterState:
|
|||||||
)
|
)
|
||||||
# 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
|
||||||
@@ -218,9 +224,11 @@ class TestTuiPresenterState:
|
|||||||
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;
|
||||||
@@ -426,8 +434,8 @@ class TestTuiPresenterState:
|
|||||||
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)
|
||||||
@@ -623,9 +631,9 @@ class TestTuiPresenterState:
|
|||||||
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
|
||||||
@@ -651,9 +659,9 @@ class TestTuiPresenterState:
|
|||||||
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
|
||||||
|
|
||||||
@@ -914,6 +922,59 @@ class TestLayoutShape:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
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:
|
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.
|
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user