From 2863b5d1e160c82164cef01f4d6f20c4c30c8f74 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 2 Apr 2026 17:12:54 +0000 Subject: [PATCH] feat(tui): implement first-run experience with actor selection overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the first-run experience for the TUI as specified in the specification's 'First-Run Experience' section (§ TUI Architecture). On first launch (no personas configured), a centered ActorSelectionOverlay widget is displayed guiding the user to select an actor. The overlay supports keyboard navigation (j/k), fuzzy search (/), and confirmation (enter). Selecting an actor creates a default persona and dismisses the overlay. Changes: - Add src/cleveragents/tui/first_run.py: is_first_run() detection helper and create_default_persona_for_actor() setup helper - Add src/cleveragents/tui/widgets/actor_selection_overlay.py: ActorSelectionOverlay widget with show/hide/navigate/search/confirm API and render_actor_selection() pure rendering function - Update src/cleveragents/tui/app.py: integrate first-run check in on_mount(), yield ActorSelectionOverlay in compose(), add _complete_first_run() method - Update src/cleveragents/tui/widgets/__init__.py: export ActorSelectionOverlay - Add features/tui_first_run.feature + features/steps/tui_first_run_steps.py: comprehensive Behave BDD tests covering all public API paths ISSUES CLOSED: #1007 --- features/steps/tui_first_run_steps.py | 474 ++++++++++++++++++ features/tui_first_run.feature | 196 ++++++++ src/cleveragents/tui/app.py | 16 + src/cleveragents/tui/first_run.py | 60 +++ src/cleveragents/tui/widgets/__init__.py | 2 + .../tui/widgets/actor_selection_overlay.py | 227 +++++++++ 6 files changed, 975 insertions(+) create mode 100644 features/steps/tui_first_run_steps.py create mode 100644 features/tui_first_run.feature create mode 100644 src/cleveragents/tui/first_run.py create mode 100644 src/cleveragents/tui/widgets/actor_selection_overlay.py diff --git a/features/steps/tui_first_run_steps.py b/features/steps/tui_first_run_steps.py new file mode 100644 index 000000000..5d8d4a9b0 --- /dev/null +++ b/features/steps/tui_first_run_steps.py @@ -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}" + ) diff --git a/features/tui_first_run.feature b/features/tui_first_run.feature new file mode 100644 index 000000000..20e18d55d --- /dev/null +++ b/features/tui_first_run.feature @@ -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 diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 516a9cb49..df18a911e 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -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) diff --git a/src/cleveragents/tui/first_run.py b/src/cleveragents/tui/first_run.py new file mode 100644 index 000000000..dd956b42f --- /dev/null +++ b/src/cleveragents/tui/first_run.py @@ -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 diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index 51776ce71..3aaa000eb 100644 --- a/src/cleveragents/tui/widgets/__init__.py +++ b/src/cleveragents/tui/widgets/__init__.py @@ -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", diff --git a/src/cleveragents/tui/widgets/actor_selection_overlay.py b/src/cleveragents/tui/widgets/actor_selection_overlay.py new file mode 100644 index 000000000..d515924d8 --- /dev/null +++ b/src/cleveragents/tui/widgets/actor_selection_overlay.py @@ -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) -- 2.52.0