forked from cleveragents/cleveragents-core
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user