feat(tui): implement first-run experience with actor selection overlay #1391
@@ -0,0 +1,474 @@
|
||||
"""Step definitions for tui_first_run.feature.
|
||||
|
||||
Covers:
|
||||
- is_first_run helper
|
||||
- create_default_persona_for_actor helper
|
||||
- render_actor_selection helper
|
||||
- ActorSelectionOverlay widget
|
||||
- TUI app integration (on_mount first-run detection, _complete_first_run)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared mock-Textual infrastructure (mirrors tui_app_coverage_steps.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MOCK_TEXTUAL_KEYS = [
|
||||
"textual",
|
||||
"textual.app",
|
||||
"textual.containers",
|
||||
"textual.widgets",
|
||||
]
|
||||
|
||||
|
||||
def _build_mock_textual() -> dict[str, ModuleType]:
|
||||
mock_textual = ModuleType("textual")
|
||||
mock_textual_app = ModuleType("textual.app")
|
||||
mock_textual_containers = ModuleType("textual.containers")
|
||||
mock_textual_widgets = ModuleType("textual.widgets")
|
||||
|
||||
class MockApp:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._widgets: dict[str, object] = {}
|
||||
|
||||
def query_one(self, selector: str, widget_type: type | None = None) -> object:
|
||||
if selector in self._widgets:
|
||||
return self._widgets[selector]
|
||||
if widget_type is not None:
|
||||
widget = widget_type(id=selector.lstrip("#"))
|
||||
self._widgets[selector] = widget
|
||||
return widget
|
||||
return MagicMock()
|
||||
|
||||
class MockVertical:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> MockVertical:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
class MockHeader:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
class MockFooter:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
class MockStatic:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._text = ""
|
||||
|
||||
def update(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
mock_textual_app.App = MockApp
|
||||
mock_textual_containers.Vertical = MockVertical
|
||||
mock_textual_widgets.Header = MockHeader
|
||||
mock_textual_widgets.Footer = MockFooter
|
||||
mock_textual_widgets.Static = MockStatic
|
||||
|
||||
return {
|
||||
"textual": mock_textual,
|
||||
"textual.app": mock_textual_app,
|
||||
"textual.containers": mock_textual_containers,
|
||||
"textual.widgets": mock_textual_widgets,
|
||||
}
|
||||
|
||||
|
||||
def _reload_tui_modules(mock_modules: dict[str, ModuleType]) -> None:
|
||||
"""Reload TUI modules with mocked Textual in sys.modules."""
|
||||
tui_keys = [
|
||||
key for key in list(sys.modules.keys()) if key.startswith("cleveragents.tui")
|
||||
]
|
||||
for key in tui_keys:
|
||||
del sys.modules[key]
|
||||
for name, mod in mock_modules.items():
|
||||
sys.modules[name] = mod
|
||||
|
||||
|
||||
def _restore_textual(original: dict[str, object]) -> None:
|
||||
for key in _MOCK_TEXTUAL_KEYS:
|
||||
if key in original:
|
||||
sys.modules[key] = original[key] # type: ignore[assignment]
|
||||
else:
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_registry(tmp_dir: Path) -> object:
|
||||
from cleveragents.tui.persona.registry import PersonaRegistry
|
||||
|
||||
return PersonaRegistry(config_dir=tmp_dir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_first_run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("an empty persona registry")
|
||||
def step_empty_registry(context: object) -> None:
|
||||
tmp = tempfile.mkdtemp()
|
||||
context._tmp_dir = Path(tmp)
|
||||
context._registry = _make_registry(context._tmp_dir)
|
||||
|
||||
|
||||
@given('a persona registry with a default persona using actor "{actor}"')
|
||||
def step_registry_with_persona(context: object, actor: str) -> None:
|
||||
tmp = tempfile.mkdtemp()
|
||||
context._tmp_dir = Path(tmp)
|
||||
context._registry = _make_registry(context._tmp_dir)
|
||||
from cleveragents.tui.persona.schema import Persona
|
||||
|
||||
persona = Persona(name="default", actor=actor, description="test")
|
||||
context._registry.save(persona)
|
||||
|
||||
|
||||
@then("is_first_run should return True")
|
||||
def step_is_first_run_true(context: object) -> None:
|
||||
from cleveragents.tui.first_run import is_first_run
|
||||
|
||||
assert is_first_run(context._registry) is True
|
||||
|
||||
|
||||
@then("is_first_run should return False")
|
||||
def step_is_first_run_false(context: object) -> None:
|
||||
from cleveragents.tui.first_run import is_first_run
|
||||
|
||||
assert is_first_run(context._registry) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_default_persona_for_actor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I call create_default_persona_for_actor with actor "{actor}"')
|
||||
def step_create_default_persona(context: object, actor: str) -> None:
|
||||
from cleveragents.tui.first_run import create_default_persona_for_actor
|
||||
|
||||
context._created_persona = create_default_persona_for_actor(
|
||||
context._registry, actor
|
||||
)
|
||||
|
||||
|
||||
@then('the registry should contain a persona named "{name}"')
|
||||
def step_registry_has_persona(context: object, name: str) -> None:
|
||||
persona = context._registry.get(name)
|
||||
assert persona is not None, f"Expected persona '{name}' in registry"
|
||||
|
||||
|
||||
@then('the default persona actor should be "{actor}"')
|
||||
def step_default_persona_actor(context: object, actor: str) -> None:
|
||||
persona = context._registry.get("default")
|
||||
assert persona is not None
|
||||
assert persona.actor == actor, f"Expected actor '{actor}', got '{persona.actor}'"
|
||||
|
||||
|
||||
@then('the registry last persona should be "{name}"')
|
||||
def step_registry_last_persona(context: object, name: str) -> None:
|
||||
last = context._registry.get_last_persona()
|
||||
assert last == name, f"Expected last persona '{name}', got '{last}'"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_actor_selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I render the actor selection with default actors")
|
||||
def step_render_default(context: object) -> None:
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import (
|
||||
_DEFAULT_ACTORS,
|
||||
render_actor_selection,
|
||||
)
|
||||
|
||||
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, "")
|
||||
|
||||
|
||||
@when("I render the actor selection with selected index 2")
|
||||
def step_render_selected_index(context: object) -> None:
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import (
|
||||
_DEFAULT_ACTORS,
|
||||
render_actor_selection,
|
||||
)
|
||||
|
||||
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 2, "")
|
||||
|
||||
|
||||
@then("the rendered text should contain a cursor marker")
|
||||
def step_rendered_contains_cursor(context: object) -> None:
|
||||
# The cursor character is the heavy right-pointing angle quotation mark
|
||||
# used as a selection indicator per the spec mockup.
|
||||
cursor = "\u276f"
|
||||
assert cursor in context._rendered, (
|
||||
f"Expected cursor marker in rendered text:\n{context._rendered}"
|
||||
)
|
||||
|
||||
|
||||
@when('I render the actor selection with search query "{query}"')
|
||||
def step_render_with_query(context: object, query: str) -> None:
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import (
|
||||
_DEFAULT_ACTORS,
|
||||
render_actor_selection,
|
||||
)
|
||||
|
||||
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, query)
|
||||
|
||||
|
||||
@then('the rendered text should contain "{text}"')
|
||||
def step_rendered_contains(context: object, text: str) -> None:
|
||||
assert text in context._rendered, (
|
||||
f"Expected '{text}' in rendered text:\n{context._rendered}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ActorSelectionOverlay widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a new ActorSelectionOverlay")
|
||||
def step_new_overlay(context: object) -> None:
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
||||
|
||||
context._overlay = ActorSelectionOverlay()
|
||||
|
||||
|
||||
@when("I call show on the overlay")
|
||||
def step_overlay_show(context: object) -> None:
|
||||
context._overlay.show()
|
||||
|
||||
|
||||
@when("I call hide on the overlay")
|
||||
def step_overlay_hide(context: object) -> None:
|
||||
context._overlay.hide()
|
||||
|
||||
|
||||
@when("I call show on the overlay with actors {actors_json}")
|
||||
def step_overlay_show_custom(context: object, actors_json: str) -> None:
|
||||
import json
|
||||
|
||||
actors = json.loads(actors_json)
|
||||
context._overlay.show(actors=actors)
|
||||
|
||||
|
||||
@when("I call move_down on the overlay")
|
||||
def step_overlay_move_down(context: object) -> None:
|
||||
context._overlay.move_down()
|
||||
|
||||
|
||||
@when("I call move_up on the overlay")
|
||||
def step_overlay_move_up(context: object) -> None:
|
||||
context._overlay.move_up()
|
||||
|
||||
|
||||
@when("I move_down past the last actor")
|
||||
def step_overlay_move_down_past_last(context: object) -> None:
|
||||
count = len(context._overlay.filtered_actors)
|
||||
for _ in range(count):
|
||||
context._overlay.move_down()
|
||||
|
||||
|
||||
@when('I call set_search with query "{query}"')
|
||||
def step_overlay_set_search(context: object, query: str) -> None:
|
||||
context._overlay.set_search(query)
|
||||
|
||||
|
||||
@when("I call confirm on the overlay")
|
||||
def step_overlay_confirm(context: object) -> None:
|
||||
context._confirmed_result = context._overlay.confirm()
|
||||
|
||||
|
||||
@then("the overlay should be visible")
|
||||
def step_overlay_visible(context: object) -> None:
|
||||
assert context._overlay.visible is True
|
||||
|
||||
|
||||
@then("the overlay should not be visible")
|
||||
def step_overlay_not_visible(context: object) -> None:
|
||||
assert context._overlay.visible is False
|
||||
|
||||
|
||||
@then('the overlay actors list should contain "{actor}"')
|
||||
def step_overlay_actors_contains(context: object, actor: str) -> None:
|
||||
assert actor in context._overlay.actors, (
|
||||
f"Expected '{actor}' in actors: {context._overlay.actors}"
|
||||
)
|
||||
|
||||
|
||||
@then("the overlay selected index should be 1")
|
||||
def step_overlay_selected_1(context: object) -> None:
|
||||
assert context._overlay.selected_index == 1, (
|
||||
f"Expected 1, got {context._overlay.selected_index}"
|
||||
)
|
||||
|
||||
|
||||
@then("the overlay selected index should be the last index")
|
||||
def step_overlay_selected_last(context: object) -> None:
|
||||
last = len(context._overlay.filtered_actors) - 1
|
||||
assert context._overlay.selected_index == last, (
|
||||
f"Expected {last}, got {context._overlay.selected_index}"
|
||||
)
|
||||
|
||||
|
||||
@then("the overlay selected index should be 0")
|
||||
def step_overlay_selected_0(context: object) -> None:
|
||||
assert context._overlay.selected_index == 0, (
|
||||
f"Expected 0, got {context._overlay.selected_index}"
|
||||
)
|
||||
|
||||
|
||||
@then('the overlay filtered actors should only contain actors matching "{query}"')
|
||||
def step_overlay_filtered_match(context: object, query: str) -> None:
|
||||
for actor in context._overlay.filtered_actors:
|
||||
assert query.lower() in actor.lower(), (
|
||||
f"Actor '{actor}' does not match query '{query}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the overlay filtered actors count should equal the full actors count")
|
||||
def step_overlay_filtered_count_full(context: object) -> None:
|
||||
assert len(context._overlay.filtered_actors) == len(context._overlay.actors), (
|
||||
f"Filtered: {len(context._overlay.filtered_actors)}, "
|
||||
f"Full: {len(context._overlay.actors)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the confirmed actor should be "{actor}"')
|
||||
def step_confirmed_actor(context: object, actor: str) -> None:
|
||||
assert context._confirmed_result == actor, (
|
||||
f"Expected '{actor}', got '{context._confirmed_result}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the confirmed actor should be None")
|
||||
def step_confirmed_actor_none(context: object) -> None:
|
||||
assert context._confirmed_result is None, (
|
||||
f"Expected None, got '{context._confirmed_result}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the overlay confirmed flag should be True")
|
||||
def step_overlay_confirmed_flag(context: object) -> None:
|
||||
assert context._overlay.confirmed is True
|
||||
|
||||
|
||||
@then("the overlay selected_actor should be None")
|
||||
def step_overlay_selected_actor_none(context: object) -> None:
|
||||
assert context._overlay.selected_actor is None
|
||||
|
||||
|
||||
@then('the overlay search_query should be ""')
|
||||
def step_overlay_search_query_empty(context: object) -> None:
|
||||
assert context._overlay.search_query == "", (
|
||||
f"Expected empty string, got '{context._overlay.search_query}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TUI app integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_first_run_app(context: object, *, has_personas: bool) -> None:
|
||||
"""Build a mocked TUI app for first-run integration tests."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
context._tmp_dir = Path(tmp)
|
||||
|
||||
original = {k: sys.modules[k] for k in _MOCK_TEXTUAL_KEYS if k in sys.modules}
|
||||
mock_modules = _build_mock_textual()
|
||||
_reload_tui_modules(mock_modules)
|
||||
|
||||
def _cleanup() -> None:
|
||||
_restore_textual(original)
|
||||
|
||||
context.add_cleanup(_cleanup)
|
||||
|
||||
from cleveragents.tui.persona.registry import PersonaRegistry
|
||||
from cleveragents.tui.persona.schema import Persona
|
||||
from cleveragents.tui.persona.state import PersonaState
|
||||
|
||||
registry = PersonaRegistry(config_dir=context._tmp_dir)
|
||||
if has_personas:
|
||||
persona = Persona(name="default", actor="local/mock", description="test")
|
||||
registry.save(persona)
|
||||
|
||||
context._registry = registry
|
||||
persona_state = PersonaState(registry=registry)
|
||||
|
||||
class _MockRouter:
|
||||
def handle(self, raw: str, *, session_id: str) -> str:
|
||||
return f"handled:{raw}"
|
||||
|
||||
from cleveragents.tui.app import CleverAgentsTuiApp
|
||||
|
||||
app = CleverAgentsTuiApp(command_router=_MockRouter(), persona_state=persona_state)
|
||||
context._first_run_app = app
|
||||
|
||||
|
||||
@given("the TUI app is initialised with an empty persona registry")
|
||||
def step_app_empty_registry(context: object) -> None:
|
||||
_build_first_run_app(context, has_personas=False)
|
||||
|
||||
|
||||
@given("the TUI app is initialised with an existing persona registry")
|
||||
def step_app_existing_registry(context: object) -> None:
|
||||
_build_first_run_app(context, has_personas=True)
|
||||
|
||||
|
||||
@when("I call on_mount on the first-run app")
|
||||
def step_app_on_mount(context: object) -> None:
|
||||
context._first_run_app.on_mount()
|
||||
|
||||
|
||||
@then("the actor selection overlay should be visible")
|
||||
def step_actor_overlay_visible(context: object) -> None:
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
||||
|
||||
overlay = context._first_run_app.query_one(
|
||||
"#actor-selection", ActorSelectionOverlay
|
||||
)
|
||||
assert overlay.visible is True, "Expected actor selection overlay to be visible"
|
||||
|
||||
|
||||
@then("the actor selection overlay should not be visible")
|
||||
def step_actor_overlay_not_visible(context: object) -> None:
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
||||
|
||||
overlay = context._first_run_app.query_one(
|
||||
"#actor-selection", ActorSelectionOverlay
|
||||
)
|
||||
assert overlay.visible is False, "Expected actor selection overlay to be hidden"
|
||||
|
||||
|
||||
@when('I call _complete_first_run with actor "{actor}"')
|
||||
def step_complete_first_run(context: object, actor: str) -> None:
|
||||
context._first_run_app._complete_first_run(actor)
|
||||
|
||||
|
||||
@then("the persona bar should reflect the new actor")
|
||||
def step_persona_bar_reflects_actor(context: object) -> None:
|
||||
from cleveragents.tui.widgets.persona_bar import PersonaBar
|
||||
|
||||
bar = context._first_run_app.query_one("#persona-bar", PersonaBar)
|
||||
assert "anthropic/claude-4-sonnet" in bar._text, (
|
||||
f"Expected actor in persona bar, got: {bar._text}"
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
Feature: TUI first-run experience with actor selection overlay
|
||||
The TUI shows an actor selection overlay on first launch when no
|
||||
personas are configured. After the user selects an actor a default
|
||||
persona is created and the overlay is dismissed.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# is_first_run helper
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: is_first_run returns True when registry has no personas
|
||||
Given an empty persona registry
|
||||
Then is_first_run should return True
|
||||
|
||||
Scenario: is_first_run returns False when registry has at least one persona
|
||||
Given a persona registry with a default persona using actor "local/mock"
|
||||
Then is_first_run should return False
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# create_default_persona_for_actor helper
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: create_default_persona_for_actor persists a default persona
|
||||
Given an empty persona registry
|
||||
When I call create_default_persona_for_actor with actor "anthropic/claude-4-sonnet"
|
||||
Then the registry should contain a persona named "default"
|
||||
And the default persona actor should be "anthropic/claude-4-sonnet"
|
||||
|
||||
Scenario: create_default_persona_for_actor sets last persona
|
||||
Given an empty persona registry
|
||||
When I call create_default_persona_for_actor with actor "openai/gpt-4o"
|
||||
Then the registry last persona should be "default"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# render_actor_selection helper
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: render_actor_selection includes welcome header
|
||||
When I render the actor selection with default actors
|
||||
Then the rendered text should contain "Welcome to CleverAgents"
|
||||
|
||||
Scenario: render_actor_selection marks the first actor as recommended
|
||||
When I render the actor selection with default actors
|
||||
Then the rendered text should contain "(recommended)"
|
||||
|
||||
Scenario: render_actor_selection highlights the selected actor with cursor
|
||||
When I render the actor selection with selected index 2
|
||||
Then the rendered text should contain a cursor marker
|
||||
|
||||
Scenario: render_actor_selection shows search prompt when query is empty
|
||||
When I render the actor selection with default actors
|
||||
Then the rendered text should contain "to search..."
|
||||
|
||||
Scenario: render_actor_selection shows active search query
|
||||
When I render the actor selection with search query "claude"
|
||||
Then the rendered text should contain "claude"
|
||||
|
||||
Scenario: render_actor_selection includes footer keybindings
|
||||
When I render the actor selection with default actors
|
||||
Then the rendered text should contain "enter Select"
|
||||
And the rendered text should contain "j/k Navigate"
|
||||
And the rendered text should contain "/ Search"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# ActorSelectionOverlay widget
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: ActorSelectionOverlay starts hidden
|
||||
Given a new ActorSelectionOverlay
|
||||
Then the overlay should not be visible
|
||||
|
||||
Scenario: ActorSelectionOverlay show makes it visible
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
Then the overlay should be visible
|
||||
|
||||
Scenario: ActorSelectionOverlay hide makes it invisible
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call hide on the overlay
|
||||
Then the overlay should not be visible
|
||||
|
||||
Scenario: ActorSelectionOverlay show populates default actors
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
Then the overlay actors list should contain "anthropic/claude-4-sonnet"
|
||||
|
||||
Scenario: ActorSelectionOverlay show accepts custom actor list
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay with actors ["local/mock-a", "local/mock-b"]
|
||||
Then the overlay actors list should contain "local/mock-a"
|
||||
And the overlay actors list should contain "local/mock-b"
|
||||
|
||||
Scenario: ActorSelectionOverlay move_down advances selection
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call move_down on the overlay
|
||||
Then the overlay selected index should be 1
|
||||
|
||||
Scenario: ActorSelectionOverlay move_up wraps to last item
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call move_up on the overlay
|
||||
Then the overlay selected index should be the last index
|
||||
|
||||
Scenario: ActorSelectionOverlay move_down wraps to first item
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I move_down past the last actor
|
||||
Then the overlay selected index should be 0
|
||||
|
||||
Scenario: ActorSelectionOverlay set_search filters actors
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call set_search with query "claude"
|
||||
Then the overlay filtered actors should only contain actors matching "claude"
|
||||
|
||||
Scenario: ActorSelectionOverlay set_search with empty string clears filter
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call set_search with query "claude"
|
||||
And I call set_search with query ""
|
||||
Then the overlay filtered actors count should equal the full actors count
|
||||
|
||||
Scenario: ActorSelectionOverlay confirm returns selected actor
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call confirm on the overlay
|
||||
Then the confirmed actor should be "anthropic/claude-4-sonnet"
|
||||
|
||||
Scenario: ActorSelectionOverlay confirm hides the overlay
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call confirm on the overlay
|
||||
Then the overlay should not be visible
|
||||
|
||||
Scenario: ActorSelectionOverlay confirm sets confirmed flag
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call confirm on the overlay
|
||||
Then the overlay confirmed flag should be True
|
||||
|
||||
Scenario: ActorSelectionOverlay confirm on empty filtered list returns None
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call set_search with query "zzz_no_match"
|
||||
And I call confirm on the overlay
|
||||
Then the confirmed actor should be None
|
||||
|
||||
Scenario: ActorSelectionOverlay move_down on empty list is a no-op
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call set_search with query "zzz_no_match"
|
||||
And I call move_down on the overlay
|
||||
Then the overlay selected index should be 0
|
||||
|
||||
Scenario: ActorSelectionOverlay move_up on empty list is a no-op
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
And I call set_search with query "zzz_no_match"
|
||||
And I call move_up on the overlay
|
||||
Then the overlay selected index should be 0
|
||||
|
||||
Scenario: ActorSelectionOverlay selected_actor is None before confirm
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
Then the overlay selected_actor should be None
|
||||
|
||||
Scenario: ActorSelectionOverlay search_query is empty after show
|
||||
Given a new ActorSelectionOverlay
|
||||
When I call show on the overlay
|
||||
Then the overlay search_query should be ""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# TUI app integration: on_mount shows overlay on first run
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: TUI app shows actor selection overlay on first run
|
||||
Given the TUI app is initialised with an empty persona registry
|
||||
When I call on_mount on the first-run app
|
||||
Then the actor selection overlay should be visible
|
||||
|
||||
Scenario: TUI app hides actor selection overlay when personas exist
|
||||
Given the TUI app is initialised with an existing persona registry
|
||||
When I call on_mount on the first-run app
|
||||
Then the actor selection overlay should not be visible
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# TUI app integration: _complete_first_run
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
Scenario: _complete_first_run creates default persona and refreshes bar
|
||||
Given the TUI app is initialised with an empty persona registry
|
||||
When I call on_mount on the first-run app
|
||||
And I call _complete_first_run with actor "anthropic/claude-4-sonnet"
|
||||
Then the registry should contain a persona named "default"
|
||||
And the persona bar should reflect the new actor
|
||||
@@ -7,10 +7,12 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
|
||||
|
||||
from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run
|
||||
from cleveragents.tui.input.modes import InputMode, InputModeRouter
|
||||
from cleveragents.tui.input.reference_parser import suggestions
|
||||
from cleveragents.tui.persona.state import PersonaState
|
||||
from cleveragents.tui.slash_catalog import slash_command_names
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
||||
from cleveragents.tui.widgets.help_panel_overlay import (
|
||||
HelpPanelOverlay,
|
||||
resolve_help_context,
|
||||
@@ -111,6 +113,7 @@ if _TEXTUAL_AVAILABLE:
|
||||
yield HelpPanelOverlay(id="help-panel")
|
||||
yield ReferencePickerOverlay(id="reference-picker")
|
||||
yield SlashCommandOverlay(id="slash-overlay")
|
||||
yield ActorSelectionOverlay(id="actor-selection")
|
||||
yield PromptInput(
|
||||
placeholder="Type message, /command, or !shell ...", id="prompt"
|
||||
)
|
||||
@@ -118,6 +121,9 @@ if _TEXTUAL_AVAILABLE:
|
||||
yield _Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Check first-run BEFORE _refresh_persona_bar, which calls
|
||||
# ensure_default() and would create a persona, masking the check.
|
||||
first_run = is_first_run(self._persona_state.registry)
|
||||
self._refresh_persona_bar()
|
||||
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
|
||||
help_panel.hide()
|
||||
@@ -125,6 +131,16 @@ if _TEXTUAL_AVAILABLE:
|
||||
ref_picker.set_suggestions("", [])
|
||||
slash = self.query_one("#slash-overlay", SlashCommandOverlay)
|
||||
slash.set_commands("", slash_command_names())
|
||||
actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay)
|
||||
if first_run:
|
||||
actor_overlay.show()
|
||||
else:
|
||||
actor_overlay.hide()
|
||||
|
||||
def _complete_first_run(self, actor: str) -> None:
|
||||
"""Persist the chosen actor as the default persona and refresh the bar."""
|
||||
create_default_persona_for_actor(self._persona_state.registry, actor)
|
||||
self._refresh_persona_bar()
|
||||
|
||||
def action_help(self) -> None:
|
||||
prompt = self.query_one("#prompt", PromptInput)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""First-run detection and setup helpers for the TUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cleveragents.tui.persona.registry import PersonaRegistry
|
||||
from cleveragents.tui.persona.schema import Persona
|
||||
|
||||
|
||||
def is_first_run(registry: PersonaRegistry) -> bool:
|
||||
"""Return ``True`` when no personas are configured.
|
||||
|
||||
A first-run state is defined as the persona registry containing zero
|
||||
personas. This is checked by listing all personas from the registry
|
||||
without creating any side-effects.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
registry:
|
||||
The :class:`~cleveragents.tui.persona.registry.PersonaRegistry`
|
||||
instance to inspect.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if the registry has no personas, ``False`` otherwise.
|
||||
"""
|
||||
return len(registry.list_personas()) == 0
|
||||
|
||||
|
||||
def create_default_persona_for_actor(
|
||||
registry: PersonaRegistry,
|
||||
actor: str,
|
||||
) -> Persona:
|
||||
"""Create and persist a ``"default"`` persona for the given actor.
|
||||
|
||||
This is called after the user selects an actor in the first-run
|
||||
overlay. The persona is saved to the registry and set as the last
|
||||
active persona so that subsequent launches restore it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
registry:
|
||||
The :class:`~cleveragents.tui.persona.registry.PersonaRegistry`
|
||||
instance to write to.
|
||||
actor:
|
||||
Fully-qualified actor reference (e.g. ``"anthropic/claude-4-sonnet"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Persona
|
||||
The newly created and persisted default persona.
|
||||
"""
|
||||
persona = Persona(
|
||||
name="default",
|
||||
actor=actor,
|
||||
description="Default persona",
|
||||
)
|
||||
registry.save(persona)
|
||||
registry.set_last_persona(persona.name)
|
||||
return persona
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Widget collection for CleverAgents TUI."""
|
||||
|
||||
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
||||
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
|
||||
from cleveragents.tui.widgets.persona_bar import PersonaBar
|
||||
from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted
|
||||
@@ -7,6 +8,7 @@ from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
|
||||
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
|
||||
|
||||
__all__ = [
|
||||
"ActorSelectionOverlay",
|
||||
"HelpPanelOverlay",
|
||||
"PersonaBar",
|
||||
"PromptInput",
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Actor selection overlay for the TUI first-run experience."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_DEFAULT_ACTORS: list[str] = [
|
||||
"anthropic/claude-4-sonnet",
|
||||
"anthropic/claude-4-opus",
|
||||
"openai/gpt-4o",
|
||||
"openai/o3",
|
||||
"google/gemini-2",
|
||||
]
|
||||
|
||||
|
||||
def _load_static_base() -> type[Any]:
|
||||
try:
|
||||
return importlib.import_module("textual.widgets").Static
|
||||
except Exception: # pragma: no cover - optional dependency
|
||||
|
||||
class _FallbackStatic:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self._text = ""
|
||||
|
||||
def update(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
return _FallbackStatic
|
||||
|
||||
|
||||
_StaticBase = _load_static_base()
|
||||
|
||||
|
||||
def render_actor_selection(
|
||||
actors: list[str],
|
||||
selected_index: int,
|
||||
search_query: str,
|
||||
) -> str:
|
||||
"""Render the actor selection overlay content.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
actors:
|
||||
Ordered list of actor identifiers to display.
|
||||
selected_index:
|
||||
Zero-based index of the currently highlighted actor.
|
||||
search_query:
|
||||
Current search filter string (empty means no filter active).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Rendered text content for the overlay widget.
|
||||
"""
|
||||
lines: list[str] = [
|
||||
"Welcome to CleverAgents",
|
||||
"",
|
||||
"Select an actor to get started:",
|
||||
"",
|
||||
]
|
||||
for idx, actor in enumerate(actors):
|
||||
prefix = "❯ " if idx == selected_index else " " # noqa: RUF001
|
||||
suffix = " (recommended)" if idx == 0 else ""
|
||||
lines.append(f" {prefix}{actor}{suffix}")
|
||||
lines.append(f" / {search_query if search_query else 'to search...'}")
|
||||
lines.append("")
|
||||
lines.append("A default persona will be created with this actor.")
|
||||
lines.append("You can add more actors and personas later.")
|
||||
lines.append("")
|
||||
lines.append("─" * 50)
|
||||
lines.append("enter Select │ j/k Navigate │ / Search")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ActorSelectionOverlay(_StaticBase):
|
||||
"""Centered overlay that guides actor selection on first launch.
|
||||
|
||||
The overlay is shown when no personas are configured. The user
|
||||
navigates the actor list with ``j``/``k`` (or arrow keys), filters
|
||||
with ``/``, and confirms with ``enter``. The selected actor name is
|
||||
stored in ``selected_actor`` after confirmation.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._actors: list[str] = list(_DEFAULT_ACTORS)
|
||||
self._filtered_actors: list[str] = list(_DEFAULT_ACTORS)
|
||||
self._selected_index: int = 0
|
||||
self._search_query: str = ""
|
||||
self._confirmed: bool = False
|
||||
self._selected_actor: str | None = None
|
||||
self._visible: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public read-only properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def actors(self) -> list[str]:
|
||||
"""Return the full (unfiltered) actor list."""
|
||||
return list(self._actors)
|
||||
|
||||
@property
|
||||
def filtered_actors(self) -> list[str]:
|
||||
"""Return the currently filtered actor list."""
|
||||
return list(self._filtered_actors)
|
||||
|
||||
@property
|
||||
def selected_index(self) -> int:
|
||||
"""Return the zero-based index of the highlighted actor."""
|
||||
return self._selected_index
|
||||
|
||||
@property
|
||||
def search_query(self) -> str:
|
||||
"""Return the active search filter string."""
|
||||
return self._search_query
|
||||
|
||||
@property
|
||||
def confirmed(self) -> bool:
|
||||
"""Return ``True`` if the user has confirmed a selection."""
|
||||
return self._confirmed
|
||||
|
||||
@property
|
||||
def selected_actor(self) -> str | None:
|
||||
"""Return the confirmed actor name, or ``None`` if not yet confirmed."""
|
||||
return self._selected_actor
|
||||
|
||||
@property
|
||||
def visible(self) -> bool:
|
||||
"""Return whether the overlay is currently visible."""
|
||||
return self._visible
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def show(self, actors: list[str] | None = None) -> None:
|
||||
"""Display the overlay, optionally overriding the actor list."""
|
||||
if actors is not None:
|
||||
self._actors = list(actors)
|
||||
self._filtered_actors = list(self._actors)
|
||||
self._selected_index = 0
|
||||
self._search_query = ""
|
||||
self._confirmed = False
|
||||
self._selected_actor = None
|
||||
self._visible = True
|
||||
self._render()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide the overlay and clear its content."""
|
||||
self._visible = False
|
||||
self.update("")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Navigation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def move_up(self) -> None:
|
||||
"""Move the selection cursor up by one position (wraps)."""
|
||||
if not self._filtered_actors:
|
||||
return
|
||||
self._selected_index = (self._selected_index - 1) % len(self._filtered_actors)
|
||||
self._render()
|
||||
|
||||
def move_down(self) -> None:
|
||||
"""Move the selection cursor down by one position (wraps)."""
|
||||
if not self._filtered_actors:
|
||||
return
|
||||
self._selected_index = (self._selected_index + 1) % len(self._filtered_actors)
|
||||
self._render()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Search / filter
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_search(self, query: str) -> None:
|
||||
"""Apply a search filter to the actor list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query:
|
||||
Substring to match against actor names (case-insensitive).
|
||||
An empty string clears the filter.
|
||||
"""
|
||||
self._search_query = query
|
||||
if query:
|
||||
self._filtered_actors = [
|
||||
actor for actor in self._actors if query.lower() in actor.lower()
|
||||
]
|
||||
else:
|
||||
self._filtered_actors = list(self._actors)
|
||||
self._selected_index = 0
|
||||
self._render()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confirmation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def confirm(self) -> str | None:
|
||||
"""Confirm the currently highlighted actor.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
The confirmed actor name, or ``None`` if the filtered list is
|
||||
empty.
|
||||
"""
|
||||
if not self._filtered_actors:
|
||||
return None
|
||||
actor = self._filtered_actors[self._selected_index]
|
||||
self._selected_actor = actor
|
||||
self._confirmed = True
|
||||
self.hide()
|
||||
return actor
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render(self) -> None:
|
||||
content = render_actor_selection(
|
||||
self._filtered_actors,
|
||||
self._selected_index,
|
||||
self._search_query,
|
||||
)
|
||||
self.update(content)
|
||||
Reference in New Issue
Block a user