Merge pull request 'feat(tui): implement first-run experience with actor selection overlay' (#1391) from feature/m8-tui-first-run into master

This commit is contained in:
2026-04-02 17:33:44 +00:00
committed by Forgejo
6 changed files with 975 additions and 0 deletions
+16
View File
@@ -7,10 +7,12 @@ import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run
from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
from cleveragents.tui.persona.state import PersonaState
from cleveragents.tui.slash_catalog import slash_command_names
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
from cleveragents.tui.widgets.help_panel_overlay import (
HelpPanelOverlay,
resolve_help_context,
@@ -111,6 +113,7 @@ if _TEXTUAL_AVAILABLE:
yield HelpPanelOverlay(id="help-panel")
yield ReferencePickerOverlay(id="reference-picker")
yield SlashCommandOverlay(id="slash-overlay")
yield ActorSelectionOverlay(id="actor-selection")
yield PromptInput(
placeholder="Type message, /command, or !shell ...", id="prompt"
)
@@ -118,6 +121,9 @@ if _TEXTUAL_AVAILABLE:
yield _Footer()
def on_mount(self) -> None:
# Check first-run BEFORE _refresh_persona_bar, which calls
# ensure_default() and would create a persona, masking the check.
first_run = is_first_run(self._persona_state.registry)
self._refresh_persona_bar()
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
help_panel.hide()
@@ -125,6 +131,16 @@ if _TEXTUAL_AVAILABLE:
ref_picker.set_suggestions("", [])
slash = self.query_one("#slash-overlay", SlashCommandOverlay)
slash.set_commands("", slash_command_names())
actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay)
if first_run:
actor_overlay.show()
else:
actor_overlay.hide()
def _complete_first_run(self, actor: str) -> None:
"""Persist the chosen actor as the default persona and refresh the bar."""
create_default_persona_for_actor(self._persona_state.registry, actor)
self._refresh_persona_bar()
def action_help(self) -> None:
prompt = self.query_one("#prompt", PromptInput)
+60
View File
@@ -0,0 +1,60 @@
"""First-run detection and setup helpers for the TUI."""
from __future__ import annotations
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.schema import Persona
def is_first_run(registry: PersonaRegistry) -> bool:
"""Return ``True`` when no personas are configured.
A first-run state is defined as the persona registry containing zero
personas. This is checked by listing all personas from the registry
without creating any side-effects.
Parameters
----------
registry:
The :class:`~cleveragents.tui.persona.registry.PersonaRegistry`
instance to inspect.
Returns
-------
bool
``True`` if the registry has no personas, ``False`` otherwise.
"""
return len(registry.list_personas()) == 0
def create_default_persona_for_actor(
registry: PersonaRegistry,
actor: str,
) -> Persona:
"""Create and persist a ``"default"`` persona for the given actor.
This is called after the user selects an actor in the first-run
overlay. The persona is saved to the registry and set as the last
active persona so that subsequent launches restore it.
Parameters
----------
registry:
The :class:`~cleveragents.tui.persona.registry.PersonaRegistry`
instance to write to.
actor:
Fully-qualified actor reference (e.g. ``"anthropic/claude-4-sonnet"``).
Returns
-------
Persona
The newly created and persisted default persona.
"""
persona = Persona(
name="default",
actor=actor,
description="Default persona",
)
registry.save(persona)
registry.set_last_persona(persona.name)
return persona
+2
View File
@@ -1,5 +1,6 @@
"""Widget collection for CleverAgents TUI."""
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted
@@ -8,6 +9,7 @@ from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget
__all__ = [
"ActorSelectionOverlay",
"HelpPanelOverlay",
"PersonaBar",
"PromptInput",
@@ -0,0 +1,227 @@
"""Actor selection overlay for the TUI first-run experience."""
from __future__ import annotations
import importlib
from typing import Any
_DEFAULT_ACTORS: list[str] = [
"anthropic/claude-4-sonnet",
"anthropic/claude-4-opus",
"openai/gpt-4o",
"openai/o3",
"google/gemini-2",
]
def _load_static_base() -> type[Any]:
try:
return importlib.import_module("textual.widgets").Static
except Exception: # pragma: no cover - optional dependency
class _FallbackStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
return _FallbackStatic
_StaticBase = _load_static_base()
def render_actor_selection(
actors: list[str],
selected_index: int,
search_query: str,
) -> str:
"""Render the actor selection overlay content.
Parameters
----------
actors:
Ordered list of actor identifiers to display.
selected_index:
Zero-based index of the currently highlighted actor.
search_query:
Current search filter string (empty means no filter active).
Returns
-------
str
Rendered text content for the overlay widget.
"""
lines: list[str] = [
"Welcome to CleverAgents",
"",
"Select an actor to get started:",
"",
]
for idx, actor in enumerate(actors):
prefix = " " if idx == selected_index else " " # noqa: RUF001
suffix = " (recommended)" if idx == 0 else ""
lines.append(f" {prefix}{actor}{suffix}")
lines.append(f" / {search_query if search_query else 'to search...'}")
lines.append("")
lines.append("A default persona will be created with this actor.")
lines.append("You can add more actors and personas later.")
lines.append("")
lines.append("" * 50)
lines.append("enter Select │ j/k Navigate │ / Search")
return "\n".join(lines)
class ActorSelectionOverlay(_StaticBase):
"""Centered overlay that guides actor selection on first launch.
The overlay is shown when no personas are configured. The user
navigates the actor list with ``j``/``k`` (or arrow keys), filters
with ``/``, and confirms with ``enter``. The selected actor name is
stored in ``selected_actor`` after confirmation.
"""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self._actors: list[str] = list(_DEFAULT_ACTORS)
self._filtered_actors: list[str] = list(_DEFAULT_ACTORS)
self._selected_index: int = 0
self._search_query: str = ""
self._confirmed: bool = False
self._selected_actor: str | None = None
self._visible: bool = False
# ------------------------------------------------------------------
# Public read-only properties
# ------------------------------------------------------------------
@property
def actors(self) -> list[str]:
"""Return the full (unfiltered) actor list."""
return list(self._actors)
@property
def filtered_actors(self) -> list[str]:
"""Return the currently filtered actor list."""
return list(self._filtered_actors)
@property
def selected_index(self) -> int:
"""Return the zero-based index of the highlighted actor."""
return self._selected_index
@property
def search_query(self) -> str:
"""Return the active search filter string."""
return self._search_query
@property
def confirmed(self) -> bool:
"""Return ``True`` if the user has confirmed a selection."""
return self._confirmed
@property
def selected_actor(self) -> str | None:
"""Return the confirmed actor name, or ``None`` if not yet confirmed."""
return self._selected_actor
@property
def visible(self) -> bool:
"""Return whether the overlay is currently visible."""
return self._visible
# ------------------------------------------------------------------
# Lifecycle helpers
# ------------------------------------------------------------------
def show(self, actors: list[str] | None = None) -> None:
"""Display the overlay, optionally overriding the actor list."""
if actors is not None:
self._actors = list(actors)
self._filtered_actors = list(self._actors)
self._selected_index = 0
self._search_query = ""
self._confirmed = False
self._selected_actor = None
self._visible = True
self._render()
def hide(self) -> None:
"""Hide the overlay and clear its content."""
self._visible = False
self.update("")
# ------------------------------------------------------------------
# Navigation
# ------------------------------------------------------------------
def move_up(self) -> None:
"""Move the selection cursor up by one position (wraps)."""
if not self._filtered_actors:
return
self._selected_index = (self._selected_index - 1) % len(self._filtered_actors)
self._render()
def move_down(self) -> None:
"""Move the selection cursor down by one position (wraps)."""
if not self._filtered_actors:
return
self._selected_index = (self._selected_index + 1) % len(self._filtered_actors)
self._render()
# ------------------------------------------------------------------
# Search / filter
# ------------------------------------------------------------------
def set_search(self, query: str) -> None:
"""Apply a search filter to the actor list.
Parameters
----------
query:
Substring to match against actor names (case-insensitive).
An empty string clears the filter.
"""
self._search_query = query
if query:
self._filtered_actors = [
actor for actor in self._actors if query.lower() in actor.lower()
]
else:
self._filtered_actors = list(self._actors)
self._selected_index = 0
self._render()
# ------------------------------------------------------------------
# Confirmation
# ------------------------------------------------------------------
def confirm(self) -> str | None:
"""Confirm the currently highlighted actor.
Returns
-------
str | None
The confirmed actor name, or ``None`` if the filtered list is
empty.
"""
if not self._filtered_actors:
return None
actor = self._filtered_actors[self._selected_index]
self._selected_actor = actor
self._confirmed = True
self.hide()
return actor
# ------------------------------------------------------------------
# Internal rendering
# ------------------------------------------------------------------
def _render(self) -> None:
content = render_actor_selection(
self._filtered_actors,
self._selected_index,
self._search_query,
)
self.update(content)