diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..1818f50ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ template-copy/fallback delegation paths, and the existing-empty-DB branch in `features/fast_init_upgrade.feature`. Uses race-safe temp-path allocation (`mkstemp`/`mkdtemp`) throughout new fast-init test steps. (#733) +- Added a first-run TUI actor-selection overlay that appears when no + personas are configured. The app now defers default-persona creation + until an actor is explicitly selected, with Behave and Robot coverage + for the overlay and selection flow. (#1007) + - Expanded the TUI slash command overlay catalog to include 67 commands across 14 groups, aligned with the specification command reference for session, persona, scope, plan, project, registry/config, context, and utility flows. diff --git a/features/steps/tui_actor_selection_overlay_coverage_steps.py b/features/steps/tui_actor_selection_overlay_coverage_steps.py new file mode 100644 index 000000000..1271016dd --- /dev/null +++ b/features/steps/tui_actor_selection_overlay_coverage_steps.py @@ -0,0 +1,46 @@ +"""Step definitions for tui_actor_selection_overlay_coverage.feature.""" + +from __future__ import annotations + +from behave import given, then, when + +from cleveragents.tui.widgets.actor_selection_overlay import ( + DEFAULT_FIRST_RUN_ACTORS, + ActorSelectionOverlay, +) + + +@given("a fresh actor selection overlay") +def step_fresh_actor_overlay(context): + context.actor_overlay = ActorSelectionOverlay() + + +@when("I load the default first-run actors into the overlay") +def step_load_default_actors(context): + context.actor_overlay.set_actors(DEFAULT_FIRST_RUN_ACTORS) + + +@then('the actor selection overlay text should contain "{text}"') +def step_overlay_contains(context, text): + assert text in context.actor_overlay._text + + +@when("I move the actor selection by {delta:d}") +def step_move_actor_selection(context, delta): + context.selected_actor = context.actor_overlay.move_selection(delta) + + +@then('the current actor selection should be "{name}"') +def step_current_actor_selection(context, name): + assert context.selected_actor == name + assert context.actor_overlay.current_actor() == name + + +@when("I clear the actor selection overlay") +def step_clear_actor_selection_overlay(context): + context.actor_overlay.clear() + + +@then("the actor selection overlay text should be empty") +def step_overlay_empty(context): + assert context.actor_overlay._text == "" diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..49695bcfd 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -115,12 +115,14 @@ def _install_mock_textual(context): sys.modules[key] = mod # Reload widget modules so they pick up the mock Static/Input base class + import cleveragents.tui.widgets.actor_selection_overlay as aso_mod import cleveragents.tui.widgets.help_panel_overlay as hp_mod import cleveragents.tui.widgets.persona_bar as pb_mod import cleveragents.tui.widgets.prompt as prompt_mod import cleveragents.tui.widgets.reference_picker as rp_mod import cleveragents.tui.widgets.slash_command_overlay as sco_mod + importlib.reload(aso_mod) importlib.reload(hp_mod) importlib.reload(pb_mod) importlib.reload(prompt_mod) @@ -143,12 +145,14 @@ def _restore_modules(context): sys.modules[key] = val # Reload widget modules so they pick up the real Static/Input base class again + import cleveragents.tui.widgets.actor_selection_overlay as aso_mod import cleveragents.tui.widgets.help_panel_overlay as hp_mod import cleveragents.tui.widgets.persona_bar as pb_mod import cleveragents.tui.widgets.prompt as prompt_mod import cleveragents.tui.widgets.reference_picker as rp_mod import cleveragents.tui.widgets.slash_command_overlay as sco_mod + importlib.reload(aso_mod) importlib.reload(hp_mod) importlib.reload(pb_mod) importlib.reload(prompt_mod) @@ -160,7 +164,7 @@ def _restore_modules(context): importlib.reload(app_mod) -def _make_persona_state(context): +def _make_persona_state(context, *, ensure_default: bool = True): """Create a real PersonaState backed by a temp directory.""" from cleveragents.tui.persona.registry import PersonaRegistry from cleveragents.tui.persona.state import PersonaState @@ -168,7 +172,8 @@ def _make_persona_state(context): tmp = tempfile.mkdtemp() context._tui_tmpdir = tmp registry = PersonaRegistry(config_dir=Path(tmp)) - registry.ensure_default() + if ensure_default: + registry.ensure_default() return PersonaState(registry=registry) @@ -264,6 +269,12 @@ def step_create_mock_deps(context): context._tui_persona_state = _make_persona_state(context) +@given("a mock command router and first-run persona state") +def step_create_first_run_deps(context): + context._tui_cmd_router = _FakeCommandRouter() + context._tui_persona_state = _make_persona_state(context, ensure_default=False) + + @when("I instantiate the Textual TUI app") def step_instantiate_app(context): AppClass = context._tui_app_mod._ResolvedTuiApp @@ -357,6 +368,22 @@ def step_slash_overlay_initialised(context): assert overlay._text # non-empty +@then('the actor overlay should contain "{text}"') +def step_actor_overlay_contains(context, text): + from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay + + overlay = context._tui_app.query_one("#actor-overlay", ActorSelectionOverlay) + assert text in overlay._text, f"Expected '{text}' in '{overlay._text}'" + + +@then('the persona bar should contain "{text}"') +def step_persona_bar_contains(context, text): + from cleveragents.tui.widgets.persona_bar import PersonaBar + + bar = context._tui_app.query_one("#persona-bar", PersonaBar) + assert text in bar._text, f"Expected '{text}' in '{bar._text}'" + + # --------------------------------------------------------------------------- # action_help (lines 123-125) # --------------------------------------------------------------------------- @@ -510,3 +537,30 @@ def step_alias_check(context): assert ( context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp" ) + + +@when('I select the first-run actor "{actor_name}"') +def step_select_first_run_actor(context, actor_name): + context.selected_first_run_actor = context._tui_app.select_first_run_actor( + actor_name + ) + + +@then('the selected first-run actor should be "{actor_name}"') +def step_selected_first_run_actor(context, actor_name): + assert context.selected_first_run_actor == actor_name + + +@then('the default persona actor should be "{actor_name}"') +def step_default_persona_actor(context, actor_name): + persona = context._tui_persona_state.registry.get("default") + assert persona is not None + assert persona.actor == actor_name + + +@then("the actor overlay should be cleared") +def step_actor_overlay_cleared(context): + from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay + + overlay = context._tui_app.query_one("#actor-overlay", ActorSelectionOverlay) + assert overlay._text == "" diff --git a/features/tui_actor_selection_overlay_coverage.feature b/features/tui_actor_selection_overlay_coverage.feature new file mode 100644 index 000000000..e3ea0b0fa --- /dev/null +++ b/features/tui_actor_selection_overlay_coverage.feature @@ -0,0 +1,21 @@ +Feature: TUI Actor Selection Overlay Coverage + Scenarios exercising the first-run actor picker widget. + + Scenario: actor selection overlay renders welcome copy and recommended actor + Given a fresh actor selection overlay + When I load the default first-run actors into the overlay + Then the actor selection overlay text should contain "Welcome to CleverAgents" + And the actor selection overlay text should contain "Select an actor to get started:" + And the actor selection overlay text should contain "(recommended)" + + Scenario: actor selection overlay moves the highlighted actor + Given a fresh actor selection overlay + When I load the default first-run actors into the overlay + And I move the actor selection by 1 + Then the current actor selection should be "anthropic/claude-4-opus" + + Scenario: actor selection overlay can be cleared + Given a fresh actor selection overlay + When I load the default first-run actors into the overlay + And I clear the actor selection overlay + Then the actor selection overlay text should be empty diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..186ae42dd 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -68,6 +68,13 @@ Feature: TUI App Coverage And the reference picker should have suggestions initialised And the slash overlay should have commands initialised + Scenario: on_mount shows actor selection overlay on first run + Given a mock command router and first-run persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + Then the actor overlay should contain "Select an actor to get started:" + And the persona bar should contain "(select actor)" + # --- action_help method (lines 123-125) --- Scenario: action_help opens a main-screen help panel @@ -128,6 +135,13 @@ Feature: TUI App Coverage And I submit empty text to the app Then the conversation widget should not be updated by input + Scenario: on_input_submitted is blocked until first-run actor selection completes + Given a mock command router and first-run persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I submit "plain message" to the app + Then the conversation widget should contain "Select an actor to get started." + # --- on_input_submitted with command mode (lines 152-167) --- Scenario: on_input_submitted routes slash commands @@ -179,6 +193,15 @@ Feature: TUI App Coverage Scenario: CleverAgentsTuiApp resolves to the Textual app when available Then CleverAgentsTuiApp should be _TextualCleverAgentsTuiApp on the reloaded module + Scenario: selecting a first-run actor creates the default persona + Given a mock command router and first-run persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I select the first-run actor "openai/gpt-4o" + Then the selected first-run actor should be "openai/gpt-4o" + And the default persona actor should be "openai/gpt-4o" + And the actor overlay should be cleared + # --- on_input_submitted shell with stdout output (lines 173-176) --- Scenario: on_input_submitted displays shell stdout and command diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot index c2ed219f9..4644adf95 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -62,3 +62,23 @@ TUI Help Panel Context Switching ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} tui-help-panel-ok + +TUI First Run Actor Selection Overlay + ${script}= Catenate SEPARATOR=\n + ... from types import SimpleNamespace + ... from features.steps import tui_app_coverage_steps as steps + ... context = SimpleNamespace(add_cleanup=lambda fn: None) + ... steps._install_mock_textual(context) + ... context._tui_cmd_router = steps._FakeCommandRouter() + ... context._tui_persona_state = steps._make_persona_state(context, ensure_default=False) + ... steps.step_instantiate_app(context) + ... steps.step_call_on_mount(context) + ... steps.step_select_first_run_actor(context, "openai/gpt-4o") + ... persona = context._tui_persona_state.registry.get("default") + ... assert persona is not None and persona.actor == "openai/gpt-4o" + ... steps._restore_modules(context) + ... steps._cleanup_tmpdir(context) + ... print("tui-first-run-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-first-run-ok diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 516a9cb49..3b2839cfd 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -11,6 +11,10 @@ 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 ( + DEFAULT_FIRST_RUN_ACTORS, + ActorSelectionOverlay, +) from cleveragents.tui.widgets.help_panel_overlay import ( HelpPanelOverlay, resolve_help_context, @@ -98,16 +102,22 @@ if _TEXTUAL_AVAILABLE: *, command_router: _CommandRouter, persona_state: PersonaState, + available_actors: list[str] | None = None, ) -> None: super().__init__() self._command_router = command_router self._persona_state = persona_state + self._available_actors = list(available_actors or DEFAULT_FIRST_RUN_ACTORS) + self._first_run_active = not bool( + self._persona_state.registry.list_personas() + ) self._session = SessionView(session_id="default", transcript=[]) def compose(self) -> Any: yield _Header(show_clock=True) with _Vertical(id="main-column"): yield _Static("CleverAgents TUI", id="conversation") + yield ActorSelectionOverlay(id="actor-overlay") yield HelpPanelOverlay(id="help-panel") yield ReferencePickerOverlay(id="reference-picker") yield SlashCommandOverlay(id="slash-overlay") @@ -118,7 +128,13 @@ if _TEXTUAL_AVAILABLE: yield _Footer() def on_mount(self) -> None: - self._refresh_persona_bar() + overlay = self.query_one("#actor-overlay", ActorSelectionOverlay) + if self._first_run_active: + overlay.set_actors(self._available_actors) + self._set_first_run_persona_bar() + else: + overlay.clear() + self._refresh_persona_bar() help_panel = self.query_one("#help-panel", HelpPanelOverlay) help_panel.hide() ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) @@ -133,9 +149,20 @@ if _TEXTUAL_AVAILABLE: help_panel.toggle(context_name) def action_cycle_preset(self) -> None: + if self._first_run_active: + return self._persona_state.cycle_preset(self._session.session_id) self._refresh_persona_bar() + def _set_first_run_persona_bar(self) -> None: + bar = self.query_one("#persona-bar", PersonaBar) + bar.set_content( + persona_name="default", + actor_name="(select actor)", + preset_name="default", + scope_text="0 scope refs", + ) + def _refresh_persona_bar(self) -> None: persona = self._persona_state.active_persona(self._session.session_id) preset = self._persona_state.current_preset(self._session.session_id) @@ -149,6 +176,24 @@ if _TEXTUAL_AVAILABLE: scope_text=scope_text, ) + def select_first_run_actor(self, actor_name: str | None = None) -> str: + """Create the default persona from the selected first-run actor.""" + + overlay = self.query_one("#actor-overlay", ActorSelectionOverlay) + selected = actor_name or overlay.current_actor() + if not selected: + raise ValueError("No actor available for first-run selection") + if selected not in self._available_actors: + raise ValueError(f"Unknown actor: {selected}") + self._persona_state.registry.ensure_default(actor=selected) + self._persona_state.set_active_persona(self._session.session_id, "default") + self._first_run_active = False + overlay.clear() + self._refresh_persona_bar() + conversation = self.query_one("#conversation", _Static) + conversation.update(f"Selected actor: {selected}") + return selected + def on_input_submitted(self, event: InputSubmittedEvent) -> None: del event prompt = self.query_one("#prompt", PromptInput) @@ -156,6 +201,10 @@ if _TEXTUAL_AVAILABLE: text = payload.text.strip() if not text: return + if self._first_run_active: + conversation = self.query_one("#conversation", _Static) + conversation.update("Select an actor to get started.") + return mode_router = InputModeRouter( command_handler=lambda raw: self._command_router.handle( diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 94438c763..1d639d697 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -14,6 +14,13 @@ Screen { background: $panel; } +#actor-overlay { + height: auto; + padding: 1 2; + border: round $primary; + background: $panel-lighten-1; +} + #help-panel { height: auto; max-height: 12; 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..53d8cdc67 --- /dev/null +++ b/src/cleveragents/tui/widgets/actor_selection_overlay.py @@ -0,0 +1,101 @@ +"""First-run actor selection overlay widget.""" + +from __future__ import annotations + +import importlib +from typing import Any + + +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover + + 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() + +DEFAULT_FIRST_RUN_ACTORS = [ + "anthropic/claude-4-sonnet", + "anthropic/claude-4-opus", + "openai/gpt-4o", + "openai/o3", + "google/gemini-2", +] + + +class ActorSelectionOverlay(_StaticBase): + """Renderable actor picker shown when no personas exist yet.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self._actors: list[str] = [] + self._selected_index = 0 + self._text = "" + + @property + def selected_index(self) -> int: + """Return the currently selected actor index.""" + + return self._selected_index + + def current_actor(self) -> str | None: + """Return the selected actor, if any actors are loaded.""" + + if not self._actors: + return None + return self._actors[self._selected_index] + + def set_actors(self, actors: list[str], *, selected_index: int = 0) -> None: + """Replace the actor choices rendered in the overlay.""" + + self._actors = list(actors) + if not self._actors: + self.clear() + return + self._selected_index = max(0, min(selected_index, len(self._actors) - 1)) + + lines = [ + "Welcome to CleverAgents", + "", + "Select an actor to get started:", + "", + ] + for idx, actor in enumerate(self._actors[:10]): + marker = ">" if idx == self._selected_index else " " + suffix = " (recommended)" if idx == 0 else "" + lines.append(f"{marker} {actor}{suffix}") + lines.extend( + [ + "", + "A default persona will be created with this actor.", + "You can add more actors and personas later.", + ] + ) + self._text = "\n".join(lines) + self.update(self._text) + + def move_selection(self, delta: int) -> str | None: + """Move the highlighted selection and re-render the overlay.""" + + if not self._actors: + return None + self._selected_index = (self._selected_index + delta) % len(self._actors) + self.set_actors(self._actors, selected_index=self._selected_index) + return self.current_actor() + + def clear(self) -> None: + """Clear the overlay content once first-run selection is complete.""" + + self._actors = [] + self._selected_index = 0 + self._text = "" + self.update("")