From 7106af5c098a2ba7cd8dd56960a5a9b127c83cfe Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 24 May 2026 15:06:10 -0700 Subject: [PATCH] style(tui): UI polish pass (v0.5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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… " during a turn. Patch bump (v0.5.0 → v0.5.1) per SemVer etiquette: purely cosmetic; no signature change; no caller-visible behavioral shift. --- persistent-memory.md | 7 ++-- pyproject.toml | 2 +- src/ratatoskr/tui.py | 68 ++++++++++++++++++++++++++------- tests/test_tui.py | 89 +++++++++++++++++++++++++++++++++++++------- uv.lock | 2 +- 5 files changed, 136 insertions(+), 32 deletions(-) diff --git a/persistent-memory.md b/persistent-memory.md index 18609ae..ebbb705 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 5c68931..f946671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index 88c9bc3..fb0c9a3 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -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) diff --git a/tests/test_tui.py b/tests/test_tui.py index 9be2a6f..01aadc9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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. diff --git a/uv.lock b/uv.lock index 64da077..b0b29d6 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "httpx" },