diff --git a/examples/eval-tui/eval_tui/app.py b/examples/eval-tui/eval_tui/app.py index bd15213..ab3369f 100644 --- a/examples/eval-tui/eval_tui/app.py +++ b/examples/eval-tui/eval_tui/app.py @@ -16,6 +16,7 @@ from textual.app import App, ComposeResult from textual.widgets import Footer, Header, TabbedContent, TabPane +from .help import HelpScreen from .models import RunSpec from .views import ( BuildView, @@ -92,6 +93,8 @@ class AgentixTUI(App): ("5", "show_tab('build')", "Build"), ("6", "show_tab('observability')", "Obs"), ("s", "save", "Save"), + ("t", "cycle_theme", "Theme"), + ("question_mark", "help", "Help"), ("q", "quit", "Quit"), ] @@ -109,18 +112,39 @@ def action_save(self) -> None: path = view.export_to(Path.cwd() / "agentix-rollouts.json") self.notify(f"saved {len(payload['rollouts'])} rollouts → {path.name}", timeout=3) + def action_help(self) -> None: + """Toggle the key-binding cheatsheet.""" + if isinstance(self.screen, HelpScreen): + self.pop_screen() + else: + self.push_screen(HelpScreen()) + def on_mount(self) -> None: - # Best-effort branded theme; falls back to the default if the running - # Textual version's theme API differs. + # Build the set of cycleable themes: a best-effort branded theme (falls + # back gracefully if the Textual version's theme API differs) plus a few + # always-available built-ins. Cycle with `t`. + self._themes: list[str] = [] try: from textual.theme import Theme self.register_theme( Theme(name="agentix", primary="#cc785c", secondary="#a45a45", accent="#e08a6d", dark=True) ) - self.theme = "agentix" + self._themes.append("agentix") except Exception: pass + for name in ("tokyo-night", "gruvbox", "nord", "dracula", "textual-light"): + if name in self.available_themes: + self._themes.append(name) + if self._themes: + self.theme = self._themes[0] + + def action_cycle_theme(self) -> None: + if not self._themes: + return + index = self._themes.index(self.theme) if self.theme in self._themes else -1 + self.theme = self._themes[(index + 1) % len(self._themes)] + self.notify(f"theme: {self.theme}", timeout=2) def compose(self) -> ComposeResult: yield Header(show_clock=True) diff --git a/examples/eval-tui/eval_tui/help.py b/examples/eval-tui/eval_tui/help.py new file mode 100644 index 0000000..26dc881 --- /dev/null +++ b/examples/eval-tui/eval_tui/help.py @@ -0,0 +1,73 @@ +"""Help overlay — a modal cheatsheet of the app's key bindings. + +Pressing `?` pushes this screen; `?` / `escape` / `q` dismiss it. The rows are +rendered from the running app's `BINDINGS` at display time, so the overlay +cannot advertise a key the app doesn't actually bind (and can't drift as +bindings change). +""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +# Display labels for binding names that differ from what a user would type. +_KEY_LABELS = {"question_mark": "?"} + + +def _binding_rows(bindings: object) -> list[tuple[str, str]]: + """`(key, description)` for each described binding, read from a Textual + `BINDINGS` list (tuples or `Binding` objects). Bindings without a + description are omitted — they aren't user-facing.""" + rows: list[tuple[str, str]] = [] + for b in bindings or (): # type: ignore[union-attr] + if isinstance(b, tuple): + key = b[0] + description = b[2] if len(b) > 2 else "" + else: + key = getattr(b, "key", "") + description = getattr(b, "description", "") + if key and description: + rows.append((_KEY_LABELS.get(key, key), description)) + return rows + + +def _render(rows: list[tuple[str, str]]) -> str: + width = max((len(key) for key, _ in rows), default=1) + return "\n".join(f"[b]{key.ljust(width)}[/] {desc}" for key, desc in rows) + + +class HelpScreen(ModalScreen[None]): + """Centered modal listing the app's key bindings.""" + + DEFAULT_CSS = """ + HelpScreen { + align: center middle; + background: $background 60%; + } + #help-card { + width: auto; + height: auto; + max-width: 80%; + padding: 1 3; + border: round $primary; + background: $panel; + } + #help-title { padding-bottom: 1; text-style: bold; color: $accent; } + #help-foot { padding-top: 1; color: $text-muted; } + """ + + BINDINGS = [ + ("escape", "dismiss", "Close"), + ("question_mark", "dismiss", "Close"), + ("q", "dismiss", "Close"), + ] + + def compose(self) -> ComposeResult: + rows = _binding_rows(getattr(self.app, "BINDINGS", ())) + with Vertical(id="help-card"): + yield Static("Agentix TUI — keys", id="help-title") + yield Static(_render(rows), id="help-body") + yield Static("press ? or esc to close", id="help-foot") diff --git a/examples/eval-tui/tests/test_app.py b/examples/eval-tui/tests/test_app.py index 41f30e4..811b1dd 100644 --- a/examples/eval-tui/tests/test_app.py +++ b/examples/eval-tui/tests/test_app.py @@ -7,6 +7,7 @@ from eval_tui.app import AgentixTUI from eval_tui.demo import DemoAgent, DemoDataset, DemoProvider +from eval_tui.help import HelpScreen, _binding_rows from eval_tui.models import RunSpec from eval_tui.views.build import BuildView from eval_tui.views.catalog import CatalogView, discover_catalog @@ -111,6 +112,46 @@ async def test_rollouts_export_to_writes_json(tmp_path: Path) -> None: assert len(loaded["rollouts"]) == 4 +async def test_theme_cycle_changes_theme() -> None: + app = AgentixTUI(rollout_spec=None) + async with app.run_test() as pilot: + await pilot.pause() + first = app.theme + await pilot.press("t") + await pilot.pause() + assert app.theme != first + assert app.theme in app._themes + + +async def test_help_overlay_toggles() -> None: + app = AgentixTUI(rollout_spec=None) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + assert isinstance(app.screen, HelpScreen) + await pilot.press("escape") + await pilot.pause() + assert not isinstance(app.screen, HelpScreen) + + +def test_help_rows_match_real_bindings() -> None: + # The overlay is derived from the app's BINDINGS, so every advertised key + # must map to a real binding (regression guard against a phantom row). + rows = _binding_rows(AgentixTUI.BINDINGS) + keys = {key for key, _ in rows} + descriptions = {desc for _, desc in rows} + bound_keys = {b[0] for b in AgentixTUI.BINDINGS} + + assert {"Help", "Quit", "Overview"} <= descriptions + assert "?" in keys # question_mark normalized for display + assert "t" in keys # theme switcher is bound on this stack, so it's listed + # Every advertised key corresponds to a real binding (allowing the + # display-normalized '?' for 'question_mark'). + for key in keys: + assert key in bound_keys or key == "?" + + async def test_build_view_constructs_command_from_path() -> None: app = AgentixTUI(rollout_spec=None) async with app.run_test() as pilot: