feat(tui): implement first-run experience with actor selection overlay #1248
@@ -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.
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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("")
|
||||
Reference in New Issue
Block a user
Suggestion (non-blocking): The
[:10]slice silently truncates the actor list. Consider extracting this as a named constant like_MAX_DISPLAYED_ACTORS = 10for self-documentation. Currently if someone passes 15 actors, only 10 will render with no indication to the caller.