Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions examples/eval-tui/eval_tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
]

Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions examples/eval-tui/eval_tui/help.py
Original file line number Diff line number Diff line change
@@ -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")
41 changes: 41 additions & 0 deletions examples/eval-tui/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading