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

Closed
brent.edwards wants to merge 1 commits from feature/m8-tui-first-run-actor-selection into master
10 changed files with 331 additions and 3 deletions
+5
View File
@@ -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 == ""
+56 -2
View File
@@ -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
+23
View File
@@ -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
+20
View File
@@ -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
+50 -1
View File
@@ -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)
1
@@ -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(
+7
View File
@@ -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;
+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,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.",
Outdated
Review

Suggestion (non-blocking): The [:10] slice silently truncates the actor list. Consider extracting this as a named constant like _MAX_DISPLAYED_ACTORS = 10 for self-documentation. Currently if someone passes 15 actors, only 10 will render with no indication to the caller.

**Suggestion (non-blocking):** The `[:10]` slice silently truncates the actor list. Consider extracting this as a named constant like `_MAX_DISPLAYED_ACTORS = 10` for self-documentation. Currently if someone passes 15 actors, only 10 will render with no indication to the caller.
"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("")