Compare commits

...

1 Commits

Author SHA1 Message Date
vh 7106af5c09 style(tui): UI polish pass (v0.5.1)
Cosmetic refinements on top of v0.5.0's content-only main pane. No
behavior change; ships as a patch bump.

## Color signal — terminal labels tinted per outcome

The transcript's [done]/[error]/[cancelled] labels were plain
foreground (Australis #a9bcc3 white), which made them slow to scan
against the surrounding assistant text. Now tinted per outcome:

- [done]      → Aurora green   (#16B866 / $success)
- [error]     → Dawn red       (#ff491a / $error)
- [cancelled] → Dawn yellow    (#e1c631 / $warning)

The post-Done Rule() separator is also tinted to Australis dark-60
(#86929d) so the streamed-text → markdown-body boundary reads as
chrome, not a content artifact.

## Empty-state placeholders

Tools and Debug panes were stark-empty before any turn fired — easy
to misread as "the pane is broken." Now show placeholder lines on
mount in Sea dark-50 italic:

  Tools tab: (no tool events yet — start a turn that uses tools)
  Debug tab: (waiting for telemetry — start a turn)

The placeholders scroll off naturally as real events fill the panes.

## Live thinking widget self-explains

The thinking-current Static at the top of the right column used to
just display raw thinking content with no context — an operator
glancing at the screen mid-stream might not realize they were
looking at LLM chain-of-thought. Now prefixed with "thinking… " so
the widget self-identifies.

## Spacing + chrome

- Transcript / tools-log / debug-log: 1-cell horizontal padding so
  content doesn't hug the column border.
- thinking-current: italic text-style on top of the dark-60 color,
  so the live-preview band is visually distinct from solid-colored
  log content.
- Active tab in TabbedContent: Aurora bright-cyan label + bold
  text-style, so the eye lands on the currently selected pane.
- Input placeholder text: tinted to Sea dark-50 so it reads as
  placeholder, not content.

## Test impact

3 new tests added (test_done_label_styled_success,
test_empty_state_placeholders_present, plus the polish hits
test_thinking_widget_truncation / test_thinking_coalesce updated for
the "thinking… " prefix). 4 existing tests that checked
`isinstance(w, str) and w.startswith("[done]")` updated to use the
_text_of helper (terminal labels are now RichText, not str).
_spy_writes helper widened to accept positional args after Textual's
internal deferred-render path started passing them positionally
post-Resize.

241/241 GREEN; ruff clean. Live smoke against personal Worldtree
confirmed: Done line renders in Aurora green #16B866 verbatim;
both placeholder lines appear in dark-50; thinking widget shows
"thinking… <content>" during a turn.

Patch bump (v0.5.0 → v0.5.1) per SemVer etiquette: purely cosmetic;
no signature change; no caller-visible behavioral shift.
2026-05-24 15:06:10 -07:00
5 changed files with 136 additions and 32 deletions
+4 -3
View File
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-24 (post-v0.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
View File
@@ -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
View File
@@ -118,6 +118,7 @@ _AU_ERROR = "#ff491a"
_AU_WARNING = "#e1c631"
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
# ---- Issue #12 presenter contract semantics amendment -------------------------
@@ -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
View File
@@ -61,13 +61,18 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
def _spy_writes(monkeypatch) -> list:
"""Patch RichLog.write to record every arg into a list (returned)."""
"""Patch RichLog.write to record every arg into a list (returned).
Accepts *args/**kwargs so Textual's internal deferred-render path
(which calls write positionally with width/expand/shrink/scroll_end)
still works after a write-during-mount + Resize sequence.
"""
writes: list = []
original = RichLog.write
def spy(self, content, **kw):
def spy(self, content, *args, **kw):
writes.append(content)
return original(self, content, **kw)
return original(self, content, *args, **kw)
monkeypatch.setattr(RichLog, "write", spy)
return writes
@@ -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.
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.5.0"
version = "0.5.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },