feat(tui): implement first-run experience with actor selection overlay #1391

Merged
freemo merged 1 commits from feature/m8-tui-first-run into master 2026-04-02 17:33:45 +00:00
6 changed files with 975 additions and 0 deletions
+474
View File
@@ -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}"
)
+196
View File
@@ -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
+16
View File
@@ -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)
+60
View File
@@ -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
+2
View File
@@ -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)