mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
7ffaa93dca
Adds a first-class way to install optional extras into an existing `dcode` install without dropping back to a shell or guessing package names. Surfaces it as both the `/install <extra>` slash command (in-app) and a `dcode --install EXTRA` headless flag, and rewrites the ecosystem of `pip install …` recovery hints to point at the new flow.
364 lines
11 KiB
Python
364 lines
11 KiB
Python
"""Onboarding screens for the interactive TUI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
from textual.app import ScreenStackError
|
|
from textual.binding import Binding, BindingType
|
|
from textual.containers import Vertical
|
|
from textual.content import Content
|
|
from textual.screen import ModalScreen
|
|
from textual.widgets import Input, Static
|
|
|
|
if TYPE_CHECKING:
|
|
from textual.app import ComposeResult
|
|
from textual.screen import Screen
|
|
|
|
from deepagents_code.extras_info import ExtraDependencyStatus
|
|
|
|
from deepagents_code import theme
|
|
from deepagents_code.config import get_glyphs, is_ascii_mode
|
|
from deepagents_code.extras_info import MODEL_PROVIDER_EXTRAS, SANDBOX_EXTRAS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_EXTRA_LIST_LIMIT = 8
|
|
"""Maximum extra names shown inline before summarizing the remainder."""
|
|
|
|
|
|
def _normalize_name(value: str) -> str:
|
|
"""Normalize submitted onboarding names for display.
|
|
|
|
Args:
|
|
value: Raw submitted name.
|
|
|
|
Returns:
|
|
The stripped name, title-cased when it was entered in lowercase.
|
|
"""
|
|
name = value.strip()
|
|
if name.islower():
|
|
return name.title()
|
|
return name
|
|
|
|
|
|
class LaunchNameScreen(ModalScreen[str | None]):
|
|
"""First-step onboarding screen that asks for the user's name.
|
|
|
|
Dismissal values:
|
|
|
|
- Non-empty stripped/title-cased name when the user submits one.
|
|
- `""` when the user submits an empty input (continue, but skip name memory).
|
|
- `None` when the user dismisses with Escape (skip remaining onboarding).
|
|
"""
|
|
|
|
AUTO_FOCUS = "#launch-name-input"
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("escape", "skip", "Skip", show=False, priority=True),
|
|
]
|
|
|
|
CSS = """
|
|
LaunchNameScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
LaunchNameScreen > Vertical {
|
|
width: 64;
|
|
max-width: 90%;
|
|
height: auto;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
padding: 1 2;
|
|
}
|
|
|
|
LaunchNameScreen .launch-init-title {
|
|
text-style: bold;
|
|
color: $primary;
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
LaunchNameScreen .launch-init-copy {
|
|
height: auto;
|
|
color: $text;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
LaunchNameScreen #launch-name-input {
|
|
margin-bottom: 1;
|
|
border: solid $primary-lighten-2;
|
|
}
|
|
|
|
LaunchNameScreen #launch-name-input:focus {
|
|
border: solid $primary;
|
|
}
|
|
|
|
LaunchNameScreen .launch-init-help {
|
|
height: 1;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual override
|
|
"""Compose the name-entry screen.
|
|
|
|
Yields:
|
|
Widgets for the modal content.
|
|
"""
|
|
glyphs = get_glyphs()
|
|
with Vertical():
|
|
yield Static("Welcome to Deep Agents", classes="launch-init-title")
|
|
yield Static(
|
|
Content.assemble(
|
|
"What should Deep Agents call you? This is optional and "
|
|
"will be remembered for future sessions."
|
|
),
|
|
classes="launch-init-copy",
|
|
)
|
|
yield Input(
|
|
placeholder="Your name (optional)",
|
|
id="launch-name-input",
|
|
)
|
|
yield Static(
|
|
f"Enter to continue {glyphs.bullet} Esc skip setup",
|
|
classes="launch-init-help",
|
|
)
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply ASCII border when needed."""
|
|
if is_ascii_mode():
|
|
container = self.query_one(Vertical)
|
|
colors = theme.get_theme_colors(self)
|
|
container.styles.border = ("ascii", colors.success)
|
|
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
"""Dismiss with the submitted name.
|
|
|
|
Args:
|
|
event: The input submission event.
|
|
"""
|
|
event.stop()
|
|
value = _normalize_name(event.value)
|
|
self.dismiss(value)
|
|
|
|
def action_skip(self) -> None:
|
|
"""Skip the onboarding sequence."""
|
|
self.dismiss(None)
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Alias for `action_skip` invoked by the global Esc binding.
|
|
|
|
Textual's `Screen.action_cancel` is the conventional cancel hook used
|
|
by the app-level Esc handler in `DeepAgentsApp`; routing it to
|
|
`action_skip` keeps the screen-specific binding and the global path
|
|
in sync.
|
|
"""
|
|
self.action_skip()
|
|
|
|
|
|
class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
|
"""Onboarding screen that summarizes installed optional integrations."""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("enter", "continue", "Continue", show=False, priority=True),
|
|
Binding("escape", "skip", "Skip", show=False, priority=True),
|
|
]
|
|
|
|
CSS = """
|
|
LaunchDependenciesScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
LaunchDependenciesScreen > Vertical {
|
|
width: 76;
|
|
max-width: 90%;
|
|
height: auto;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
padding: 1 2;
|
|
}
|
|
|
|
LaunchDependenciesScreen .launch-init-title {
|
|
text-style: bold;
|
|
color: $primary;
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
LaunchDependenciesScreen .launch-init-copy {
|
|
height: auto;
|
|
color: $text-muted;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
LaunchDependenciesScreen .launch-dependencies-section {
|
|
height: auto;
|
|
color: $text;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
LaunchDependenciesScreen .launch-init-help {
|
|
height: 1;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
statuses: tuple[ExtraDependencyStatus, ...] | None = None,
|
|
*,
|
|
continue_screen: Screen[Any] | None = None,
|
|
) -> None:
|
|
"""Initialize the dependency summary screen.
|
|
|
|
Args:
|
|
statuses: Optional dependency statuses to display. When omitted,
|
|
the status is read from the installed package metadata.
|
|
continue_screen: Optional screen to switch to when the user
|
|
continues, avoiding an intermediate base-screen frame.
|
|
"""
|
|
super().__init__()
|
|
if statuses is None:
|
|
from deepagents_code.extras_info import get_optional_dependency_status
|
|
|
|
statuses = get_optional_dependency_status()
|
|
self._statuses = statuses
|
|
self._continue_screen = continue_screen
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the dependency summary screen.
|
|
|
|
Yields:
|
|
Widgets for the modal content.
|
|
"""
|
|
glyphs = get_glyphs()
|
|
with Vertical():
|
|
yield Static("Installed Integrations", classes="launch-init-title")
|
|
yield Static(
|
|
"Deep Agents uses installed optional packages to decide which "
|
|
"providers and runtime integrations are ready now.",
|
|
classes="launch-init-copy",
|
|
)
|
|
if self._statuses:
|
|
yield Static(
|
|
self._format_section(title="Ready now", ready=True),
|
|
classes="launch-dependencies-section",
|
|
)
|
|
yield Static(
|
|
self._format_section(title="Available to add", ready=False),
|
|
classes="launch-dependencies-section",
|
|
)
|
|
else:
|
|
# `get_optional_dependency_status` returns an empty tuple when
|
|
# `importlib.metadata` cannot find the distribution (editable
|
|
# install renamed, dev checkout without dist-info). Render a
|
|
# single explanatory line instead of "none detected" twice.
|
|
yield Static(
|
|
"Could not read installed dependency metadata. Reinstall "
|
|
"with `/install <extra>` to populate.",
|
|
classes="launch-dependencies-section",
|
|
)
|
|
yield Static(
|
|
f"Enter to continue {glyphs.bullet} Esc skip setup",
|
|
classes="launch-init-help",
|
|
)
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply ASCII border when needed."""
|
|
if is_ascii_mode():
|
|
container = self.query_one(Vertical)
|
|
colors = theme.get_theme_colors(self)
|
|
container.styles.border = ("ascii", colors.success)
|
|
|
|
def _format_section(self, *, title: str, ready: bool) -> str:
|
|
"""Format one status section.
|
|
|
|
Args:
|
|
title: Section title.
|
|
ready: Whether to include ready or not-yet-ready extras.
|
|
|
|
Returns:
|
|
Multi-line section text.
|
|
"""
|
|
providers = self._extra_names(MODEL_PROVIDER_EXTRAS, ready=ready)
|
|
sandboxes = self._extra_names(SANDBOX_EXTRAS, ready=ready)
|
|
return "\n".join(
|
|
[
|
|
title,
|
|
f" Model providers: {_format_extra_names(providers)}",
|
|
f" Sandboxes: {_format_extra_names(sandboxes)}",
|
|
]
|
|
)
|
|
|
|
def _extra_names(self, names: frozenset[str], *, ready: bool) -> list[str]:
|
|
"""Return sorted extra names matching a category and readiness state.
|
|
|
|
Args:
|
|
names: Category names to include.
|
|
ready: Desired readiness state.
|
|
|
|
Returns:
|
|
Sorted matching extra names.
|
|
"""
|
|
return sorted(
|
|
status.name
|
|
for status in self._statuses
|
|
if status.name in names and status.ready is ready
|
|
)
|
|
|
|
def action_continue(self) -> None:
|
|
"""Continue onboarding."""
|
|
if self._continue_screen is not None:
|
|
try:
|
|
self.app.switch_screen(self._continue_screen)
|
|
except ScreenStackError:
|
|
# Stack was torn down (app exiting, screen popped under us).
|
|
# Fall back to dismissal so the launch-init task can finish
|
|
# rather than leaving the user staring at this modal.
|
|
logger.warning(
|
|
"Could not switch to continue screen; dismissing instead",
|
|
exc_info=True,
|
|
)
|
|
self.app.notify(
|
|
"Could not open the model selector. Use /model to pick "
|
|
"one when you're ready.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
self.dismiss(True)
|
|
return
|
|
self.dismiss(True)
|
|
|
|
def action_skip(self) -> None:
|
|
"""Skip the remaining onboarding sequence."""
|
|
self.dismiss(None)
|
|
|
|
def action_cancel(self) -> None:
|
|
"""See `LaunchNameScreen.action_cancel`."""
|
|
self.action_skip()
|
|
|
|
|
|
def _format_extra_names(names: list[str]) -> str:
|
|
"""Format extra names for compact display.
|
|
|
|
Args:
|
|
names: Extra names to display.
|
|
|
|
Returns:
|
|
Comma-separated extra names, or a placeholder when empty.
|
|
"""
|
|
if not names:
|
|
return "none detected"
|
|
shown = names[:_EXTRA_LIST_LIMIT]
|
|
rendered = ", ".join(shown)
|
|
remaining = len(names) - len(shown)
|
|
if remaining > 0:
|
|
rendered = f"{rendered}, +{remaining} more"
|
|
return rendered
|