mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): improve onboarding Installed Integrations screen (#4195)
Onboarding's Installed Integrations screen now lists all available integrations (no "+N more" truncation) with clearer per-category rows and guidance on how to add them. --- The first-run "Installed Integrations" onboarding screen truncated the "Available to add" list at 8 names with "+N more" - which hid most addable extras since a fresh install has almost everything in that section. This reworks the screen to list every extra as per-category rows with status glyphs and counts (no truncation), adds the previously-dropped Other/standalone category (`quickjs`), makes the body scrollable, and adds copy pointing at the existing install paths (the model selector that follows installs your provider; `/install <name>` for anything else) so the info is actionable without blocking onboarding. The onboarding/default recommended selector intentionally favors the curated recommendation set over the full installed-model list. This keeps first-run setup narrow and predictable, even when users already have custom or non-recommended providers installed. Made by [Open SWE](https://openswe.vercel.app/agents/f342522b-99b6-17d0-756d-87cf23066279) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -2387,10 +2387,22 @@ class DeepAgentsApp(App):
|
||||
self._chat_input.focus_input()
|
||||
|
||||
if self._launch_init_requested:
|
||||
from deepagents_code.widgets.launch_init import LaunchNameScreen
|
||||
dependency_screen, dependency_result = (
|
||||
self._build_launch_dependencies_prompt()
|
||||
)
|
||||
|
||||
name_result = self._push_screen_result_future(LaunchNameScreen())
|
||||
self._ensure_launch_init_task(name_result=name_result)
|
||||
def skip_dependency_prompt(_name: str) -> None:
|
||||
if not dependency_result.done():
|
||||
dependency_result.set_result((False, None))
|
||||
|
||||
name_result = self._push_launch_name_result_future(
|
||||
continue_screen=dependency_screen,
|
||||
on_continue_failed=skip_dependency_prompt,
|
||||
)
|
||||
self._ensure_launch_init_task(
|
||||
name_result=name_result,
|
||||
dependency_result=dependency_result,
|
||||
)
|
||||
|
||||
# Pre-import `html.entities` on the main thread before the worker
|
||||
# starts. Python 3.14 replaced the global import lock with per-module
|
||||
@@ -5910,6 +5922,40 @@ class DeepAgentsApp(App):
|
||||
self.push_screen(screen, handle_result)
|
||||
return result_future
|
||||
|
||||
def _push_launch_name_result_future(
|
||||
self,
|
||||
*,
|
||||
continue_screen: ModalScreen[Any] | None = None,
|
||||
on_continue_failed: Callable[[str], None] | None = None,
|
||||
) -> asyncio.Future[str | None]:
|
||||
"""Push the launch name modal and return its result future.
|
||||
|
||||
Args:
|
||||
continue_screen: Optional screen that replaces the name modal after
|
||||
submit, avoiding a frame where the base app is exposed.
|
||||
on_continue_failed: Optional callback invoked with the submitted
|
||||
name if replacing the name modal fails.
|
||||
|
||||
Returns:
|
||||
Future completed with the submitted name or `None` when skipped.
|
||||
"""
|
||||
from deepagents_code.widgets.launch_init import LaunchNameScreen
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result_future: asyncio.Future[str | None] = loop.create_future()
|
||||
|
||||
def handle_result(result: str | None) -> None:
|
||||
if not result_future.done():
|
||||
result_future.set_result(result)
|
||||
|
||||
screen = LaunchNameScreen(
|
||||
continue_screen=continue_screen,
|
||||
on_continue=handle_result if continue_screen is not None else None,
|
||||
on_continue_failed=on_continue_failed,
|
||||
)
|
||||
self.push_screen(screen, handle_result)
|
||||
return result_future
|
||||
|
||||
async def _push_screen_wait(
|
||||
self,
|
||||
screen: ModalScreen[ScreenResultT],
|
||||
@@ -5929,12 +5975,15 @@ class DeepAgentsApp(App):
|
||||
self,
|
||||
*,
|
||||
name_result: Awaitable[str | None] | None = None,
|
||||
dependency_result: Awaitable[tuple[bool, tuple[str, str] | None]] | None = None,
|
||||
) -> asyncio.Task[None]:
|
||||
"""Start the onboarding task if needed.
|
||||
|
||||
Args:
|
||||
name_result: Optional pre-pushed name-screen result. Used during
|
||||
app mount so the modal is present before the first frame.
|
||||
dependency_result: Optional pre-wired dependency/model result. Used
|
||||
when the name screen switches directly to the dependency screen.
|
||||
|
||||
Returns:
|
||||
The active onboarding task.
|
||||
@@ -5948,7 +5997,10 @@ class DeepAgentsApp(App):
|
||||
task = asyncio.create_task(self._run_launch_init_sequence())
|
||||
else:
|
||||
task = asyncio.create_task(
|
||||
self._run_launch_init_sequence(name_result=name_result),
|
||||
self._run_launch_init_sequence(
|
||||
name_result=name_result,
|
||||
dependency_result=dependency_result,
|
||||
),
|
||||
)
|
||||
self._launch_init_task = task
|
||||
|
||||
@@ -5964,6 +6016,7 @@ class DeepAgentsApp(App):
|
||||
self,
|
||||
*,
|
||||
name_result: Awaitable[str | None] | None = None,
|
||||
dependency_result: Awaitable[tuple[bool, tuple[str, str] | None]] | None = None,
|
||||
) -> None:
|
||||
"""Run the onboarding flow."""
|
||||
if self._launch_init_running:
|
||||
@@ -5988,10 +6041,13 @@ class DeepAgentsApp(App):
|
||||
self._write_launch_name_memory(name),
|
||||
)
|
||||
|
||||
(
|
||||
dependency_continued,
|
||||
result,
|
||||
) = await self._prompt_launch_dependencies_then_model()
|
||||
if dependency_result is None:
|
||||
(
|
||||
dependency_continued,
|
||||
result,
|
||||
) = await self._prompt_launch_dependencies_then_model()
|
||||
else:
|
||||
dependency_continued, result = await dependency_result
|
||||
if not dependency_continued:
|
||||
await self._await_launch_name_memory(name_memory_task)
|
||||
await self._finish_launch_init(name=name)
|
||||
@@ -6002,7 +6058,7 @@ class DeepAgentsApp(App):
|
||||
await self._finish_launch_init(name=name)
|
||||
return
|
||||
|
||||
model_spec, _provider = result
|
||||
model_spec, provider = result
|
||||
if self._connecting:
|
||||
# Bound the wait so a stuck server never traps onboarding.
|
||||
# Server startup typically completes in seconds; a minute is
|
||||
@@ -6030,7 +6086,7 @@ class DeepAgentsApp(App):
|
||||
await self._await_launch_name_memory(name_memory_task)
|
||||
return
|
||||
try:
|
||||
await self._switch_model(model_spec, announce_unchanged=False)
|
||||
await self._switch_or_install_launch_model(model_spec, provider)
|
||||
except Exception as exc: # surface to user, don't crash onboarding
|
||||
logger.warning(
|
||||
"Model switch during onboarding failed",
|
||||
@@ -6064,6 +6120,29 @@ class DeepAgentsApp(App):
|
||||
if self._chat_input:
|
||||
self._chat_input.focus_input()
|
||||
|
||||
async def _switch_or_install_launch_model(
|
||||
self,
|
||||
model_spec: str,
|
||||
provider: str,
|
||||
) -> None:
|
||||
"""Install a missing provider extra before switching from onboarding.
|
||||
|
||||
Args:
|
||||
model_spec: The selected `provider:model` spec.
|
||||
provider: Provider returned by the model selector.
|
||||
"""
|
||||
if provider:
|
||||
from deepagents_code.config_manifest import (
|
||||
is_provider_package_installed,
|
||||
provider_install_extra,
|
||||
)
|
||||
|
||||
extra = provider_install_extra(provider)
|
||||
if extra is not None and not is_provider_package_installed(provider):
|
||||
await self._install_extra_then_switch(extra, model_spec)
|
||||
return
|
||||
await self._switch_model(model_spec, announce_unchanged=False)
|
||||
|
||||
async def _finish_launch_init(self, *, name: str | None) -> None:
|
||||
"""Persist onboarding completion and, when given, mount the welcome.
|
||||
|
||||
@@ -6151,28 +6230,33 @@ class DeepAgentsApp(App):
|
||||
def _build_launch_dependencies_screen(
|
||||
*,
|
||||
continue_screen: ModalScreen[Any] | None = None,
|
||||
on_done: Callable[[bool | None], None] | None = None,
|
||||
) -> ModalScreen:
|
||||
"""Build the onboarding optional-dependency summary screen.
|
||||
|
||||
Args:
|
||||
continue_screen: Optional screen to switch to when continuing.
|
||||
on_done: Optional callback invoked when the dependency screen finishes
|
||||
without switching to the model selector.
|
||||
|
||||
Returns:
|
||||
Dependency summary modal.
|
||||
"""
|
||||
from deepagents_code.widgets.launch_init import LaunchDependenciesScreen
|
||||
|
||||
return LaunchDependenciesScreen(continue_screen=continue_screen)
|
||||
return LaunchDependenciesScreen(
|
||||
continue_screen=continue_screen,
|
||||
on_done=on_done,
|
||||
)
|
||||
|
||||
async def _prompt_launch_dependencies_then_model(
|
||||
def _build_launch_dependencies_prompt(
|
||||
self,
|
||||
) -> tuple[bool, tuple[str, str] | None]:
|
||||
"""Show dependencies, then replace that modal with model selection.
|
||||
) -> tuple[ModalScreen, asyncio.Future[tuple[bool, tuple[str, str] | None]]]:
|
||||
"""Build the dependency/model prompt screen and result future.
|
||||
|
||||
Returns:
|
||||
A tuple where the first value indicates whether the user continued
|
||||
past the dependency screen, and the second is the selected model
|
||||
result when one was chosen.
|
||||
The dependency screen and a future resolved when the dependency or
|
||||
model screen finishes.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
result_future: asyncio.Future[tuple[bool, tuple[str, str] | None]] = (
|
||||
@@ -6186,21 +6270,35 @@ class DeepAgentsApp(App):
|
||||
def handle_model(result: tuple[str, str] | None) -> None:
|
||||
finish((True, result))
|
||||
|
||||
model_screen = self._build_model_selector_screen(
|
||||
curated=True,
|
||||
result_callback=handle_model,
|
||||
)
|
||||
dependency_screen = self._build_launch_dependencies_screen(
|
||||
continue_screen=model_screen,
|
||||
)
|
||||
|
||||
def handle_dependencies(result: bool | None) -> None:
|
||||
if result is None:
|
||||
finish((False, None))
|
||||
elif result is True:
|
||||
finish((True, None))
|
||||
|
||||
self.push_screen(dependency_screen, handle_dependencies)
|
||||
model_screen = self._build_model_selector_screen(
|
||||
curated=True,
|
||||
result_callback=handle_model,
|
||||
)
|
||||
dependency_screen = self._build_launch_dependencies_screen(
|
||||
continue_screen=model_screen,
|
||||
on_done=handle_dependencies,
|
||||
)
|
||||
|
||||
return dependency_screen, result_future
|
||||
|
||||
async def _prompt_launch_dependencies_then_model(
|
||||
self,
|
||||
) -> tuple[bool, tuple[str, str] | None]:
|
||||
"""Show dependencies, then replace that modal with model selection.
|
||||
|
||||
Returns:
|
||||
A tuple where the first value indicates whether the user continued
|
||||
past the dependency screen, and the second is the selected model
|
||||
result when one was chosen.
|
||||
"""
|
||||
dependency_screen, result_future = self._build_launch_dependencies_prompt()
|
||||
self.push_screen(dependency_screen)
|
||||
return await result_future
|
||||
|
||||
def _can_bypass_queue(self, value: str) -> bool:
|
||||
|
||||
@@ -7,25 +7,39 @@ 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.containers import Vertical, VerticalScroll
|
||||
from textual.content import Content
|
||||
from textual.css.query import NoMatches
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
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 is_ascii_mode
|
||||
from deepagents_code.extras_info import MODEL_PROVIDER_EXTRAS, SANDBOX_EXTRAS
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.extras_info import (
|
||||
MODEL_PROVIDER_EXTRAS,
|
||||
SANDBOX_EXTRAS,
|
||||
STANDALONE_EXTRAS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXTRA_LIST_LIMIT = 8
|
||||
"""Maximum extra names shown inline before summarizing the remainder."""
|
||||
_DEPENDENCY_BODY_MAX_HEIGHT = 16
|
||||
"""Upper bound (in cells) for the scrollable dependency list.
|
||||
|
||||
Keep in sync with the `max-height: 16` in the `#launch-dependencies-body` CSS;
|
||||
Textual CSS cannot reference Python constants, so the static cap and the
|
||||
runtime `_fit_dependencies_body` clamp must agree.
|
||||
"""
|
||||
_DEPENDENCY_BODY_MIN_HEIGHT = 1
|
||||
"""Floor (in cells) so the list never collapses to zero on tiny terminals."""
|
||||
|
||||
|
||||
def _normalize_name(value: str) -> str:
|
||||
@@ -55,6 +69,27 @@ class LaunchNameScreen(ModalScreen[str | None]):
|
||||
|
||||
AUTO_FOCUS = "#launch-name-input"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
continue_screen: Screen[Any] | None = None,
|
||||
on_continue: Callable[[str], None] | None = None,
|
||||
on_continue_failed: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the name-entry screen.
|
||||
|
||||
Args:
|
||||
continue_screen: Optional screen to switch to after submitting a name.
|
||||
on_continue: Optional callback invoked with the submitted name before
|
||||
switching to `continue_screen`.
|
||||
on_continue_failed: Optional callback invoked with the submitted
|
||||
name when switching to `continue_screen` fails.
|
||||
"""
|
||||
super().__init__()
|
||||
self._continue_screen = continue_screen
|
||||
self._on_continue = on_continue
|
||||
self._on_continue_failed = on_continue_failed
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "skip", "Skip", show=False, priority=True),
|
||||
]
|
||||
@@ -132,14 +167,28 @@ class LaunchNameScreen(ModalScreen[str | None]):
|
||||
container.styles.border = ("ascii", colors.success)
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Dismiss with the submitted name.
|
||||
"""Continue with the submitted name.
|
||||
|
||||
Args:
|
||||
event: The input submission event.
|
||||
"""
|
||||
event.stop()
|
||||
value = _normalize_name(event.value)
|
||||
self.dismiss(value)
|
||||
if self._continue_screen is None:
|
||||
self.dismiss(value)
|
||||
return
|
||||
if self._on_continue is not None:
|
||||
self._on_continue(value)
|
||||
try:
|
||||
self.app.switch_screen(self._continue_screen)
|
||||
except ScreenStackError:
|
||||
logger.warning(
|
||||
"Could not switch from launch name screen; dismissing instead",
|
||||
exc_info=True,
|
||||
)
|
||||
if self._on_continue_failed is not None:
|
||||
self._on_continue_failed(value)
|
||||
self.dismiss(value)
|
||||
|
||||
def action_skip(self) -> None:
|
||||
"""Skip the onboarding sequence."""
|
||||
@@ -191,10 +240,20 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
LaunchDependenciesScreen #launch-dependencies-body {
|
||||
height: auto;
|
||||
max-height: 16; /* keep in sync with `_DEPENDENCY_BODY_MAX_HEIGHT` */
|
||||
scrollbar-gutter: stable;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
LaunchDependenciesScreen .launch-dependencies-section {
|
||||
height: auto;
|
||||
color: $text;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
LaunchDependenciesScreen .launch-dependencies-section.is-available {
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
LaunchDependenciesScreen .launch-init-help {
|
||||
@@ -210,6 +269,7 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
statuses: tuple[ExtraDependencyStatus, ...] | None = None,
|
||||
*,
|
||||
continue_screen: Screen[Any] | None = None,
|
||||
on_done: Callable[[bool | None], None] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the dependency summary screen.
|
||||
|
||||
@@ -218,6 +278,8 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
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.
|
||||
on_done: Optional callback invoked when this screen finishes without
|
||||
switching to `continue_screen`.
|
||||
"""
|
||||
super().__init__()
|
||||
if statuses is None:
|
||||
@@ -226,6 +288,7 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
statuses = get_optional_dependency_status()
|
||||
self._statuses = statuses
|
||||
self._continue_screen = continue_screen
|
||||
self._on_done = on_done
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the dependency summary screen.
|
||||
@@ -233,28 +296,45 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
Yields:
|
||||
Widgets for the modal content.
|
||||
"""
|
||||
glyphs = get_glyphs()
|
||||
with Vertical():
|
||||
yield Static("Installed Integrations", classes="launch-init-title")
|
||||
yield Static(
|
||||
"Model providers and sandboxes are enabled by optional add-on "
|
||||
"packages. The ones already present in your environment are "
|
||||
"ready to use now; you can add others anytime with `/install`.",
|
||||
"ready to use now.",
|
||||
classes="launch-init-copy",
|
||||
)
|
||||
if self._statuses:
|
||||
with VerticalScroll(id="launch-dependencies-body"):
|
||||
yield Static(
|
||||
self._format_section(
|
||||
title="Ready now",
|
||||
ready=True,
|
||||
glyph=glyphs.checkmark,
|
||||
empty="Nothing installed yet — add one below.",
|
||||
),
|
||||
classes="launch-dependencies-section",
|
||||
)
|
||||
yield Static(
|
||||
self._format_section(
|
||||
title="Available to add",
|
||||
ready=False,
|
||||
glyph=glyphs.circle_empty,
|
||||
empty="All bundled integrations are installed.",
|
||||
),
|
||||
classes="launch-dependencies-section is-available",
|
||||
)
|
||||
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",
|
||||
"Pick a model on the next screen and its provider installs "
|
||||
"automatically. Add more anytime with `/install`.",
|
||||
classes="launch-init-copy",
|
||||
)
|
||||
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.
|
||||
# single explanatory line rather than empty status sections.
|
||||
yield Static(
|
||||
"Could not read installed dependency metadata. Reinstall "
|
||||
"with `/install <extra>` to populate.",
|
||||
@@ -271,26 +351,82 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
container = self.query_one(Vertical)
|
||||
colors = theme.get_theme_colors(self)
|
||||
container.styles.border = ("ascii", colors.success)
|
||||
self.call_after_refresh(self._fit_dependencies_body)
|
||||
|
||||
def _format_section(self, *, title: str, ready: bool) -> str:
|
||||
"""Format one status section.
|
||||
def on_resize(self) -> None:
|
||||
"""Refit the scroll body when terminal dimensions change."""
|
||||
self.call_after_refresh(self._fit_dependencies_body)
|
||||
|
||||
def _fit_dependencies_body(self) -> None:
|
||||
"""Cap the dependency list height so modal controls stay in view."""
|
||||
# `#launch-dependencies-body` is only composed when statuses are
|
||||
# non-empty (see `compose`); skip the structural always-empty case
|
||||
# here. The `NoMatches` catch below still handles the teardown race.
|
||||
if not self._statuses:
|
||||
return
|
||||
|
||||
try:
|
||||
container = self.query_one(Vertical)
|
||||
body = self.query_one("#launch-dependencies-body", VerticalScroll)
|
||||
except NoMatches:
|
||||
# This runs deferred via `call_after_refresh`; the screen may have
|
||||
# been popped or recomposed before it fires (e.g. a resize racing
|
||||
# dismissal). Sizing is cosmetic, so skip quietly but leave a
|
||||
# breadcrumb rather than letting it surface in the event loop.
|
||||
logger.debug(
|
||||
"Skipping dependency-body refit; widgets not mounted",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
non_body_height = max(0, container.region.height - body.region.height)
|
||||
available_height = self.size.height - non_body_height
|
||||
max_height = max(
|
||||
_DEPENDENCY_BODY_MIN_HEIGHT,
|
||||
min(_DEPENDENCY_BODY_MAX_HEIGHT, available_height),
|
||||
)
|
||||
current = body.styles.max_height
|
||||
if current is not None and current.cells == max_height:
|
||||
return
|
||||
body.styles.max_height = max_height
|
||||
|
||||
def _format_section(
|
||||
self, *, title: str, ready: bool, glyph: str, empty: str
|
||||
) -> str:
|
||||
"""Format one status section as per-extra rows grouped by category.
|
||||
|
||||
Every matching extra is listed (no truncation); each category that
|
||||
has matches is shown under a sub-header, and the section title carries
|
||||
a total count. When nothing matches, the `empty` placeholder is shown
|
||||
in place of the sub-headers.
|
||||
|
||||
Args:
|
||||
title: Section title.
|
||||
ready: Whether to include ready or not-yet-ready extras.
|
||||
glyph: Status glyph rendered before each extra name.
|
||||
empty: Placeholder line shown when the section has no 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)}",
|
||||
]
|
||||
groups: tuple[tuple[str, frozenset[str]], ...] = (
|
||||
("Model providers", MODEL_PROVIDER_EXTRAS),
|
||||
("Sandboxes", SANDBOX_EXTRAS),
|
||||
("Other", STANDALONE_EXTRAS),
|
||||
)
|
||||
grouped = [
|
||||
(label, self._extra_names(names, ready=ready)) for label, names in groups
|
||||
]
|
||||
total = sum(len(extras) for _, extras in grouped)
|
||||
lines = [f"{title} ({total})"]
|
||||
if total == 0:
|
||||
lines.append(f" {empty}")
|
||||
return "\n".join(lines)
|
||||
for label, extras in grouped:
|
||||
if not extras:
|
||||
continue
|
||||
lines.append(f" {label}")
|
||||
lines.extend(f" {glyph} {name}" for name in extras)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _extra_names(self, names: frozenset[str], *, ready: bool) -> list[str]:
|
||||
"""Return sorted extra names matching a category and readiness state.
|
||||
@@ -327,33 +463,20 @@ class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
||||
severity="warning",
|
||||
markup=False,
|
||||
)
|
||||
self.dismiss(True)
|
||||
self._finish(True)
|
||||
return
|
||||
self.dismiss(True)
|
||||
self._finish(True)
|
||||
|
||||
def action_skip(self) -> None:
|
||||
"""Skip the remaining onboarding sequence."""
|
||||
self.dismiss(None)
|
||||
self._finish(None)
|
||||
|
||||
def _finish(self, result: bool | None) -> None:
|
||||
"""Resolve the screen-specific callback before dismissing."""
|
||||
if self._on_done is not None:
|
||||
self._on_done(result)
|
||||
self.dismiss(result)
|
||||
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical, VerticalScroll
|
||||
from textual.content import Content
|
||||
from textual.css.query import NoMatches
|
||||
from textual.events import (
|
||||
Click, # noqa: TC002 - needed at runtime for Textual event dispatch
|
||||
)
|
||||
@@ -42,6 +43,17 @@ from deepagents_code.model_config import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MODEL_LIST_MAX_HEIGHT = 16
|
||||
"""Upper bound (in cells) for the model selector list.
|
||||
|
||||
Keep in sync with the `max-height: 16` in the `.model-list` CSS below; Textual
|
||||
CSS cannot reference Python constants, so the static cap and the runtime
|
||||
`_fit_model_list` clamp must agree.
|
||||
"""
|
||||
|
||||
_MODEL_LIST_MIN_HEIGHT = 1
|
||||
"""Floor (in cells) so the model selector list never collapses to zero."""
|
||||
|
||||
_RECENT_SECTION_LABEL = "Recent"
|
||||
"""Header label for the MRU pseudo-provider section pinned at the top of `/model`.
|
||||
|
||||
@@ -57,10 +69,8 @@ _RECOMMENDED_MODELS: frozenset[str] = frozenset(
|
||||
"anthropic:claude-opus-4-7",
|
||||
"anthropic:claude-sonnet-4-6",
|
||||
"baseten:deepseek-ai/DeepSeek-V4-Pro",
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
"baseten:nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B",
|
||||
"baseten:zai-org/GLM-5",
|
||||
"baseten:zai-org/GLM-5.2",
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
|
||||
"fireworks:accounts/fireworks/models/glm-5p1",
|
||||
@@ -71,13 +81,13 @@ _RECOMMENDED_MODELS: frozenset[str] = frozenset(
|
||||
"fireworks:accounts/fireworks/models/minimax-m3",
|
||||
"fireworks:accounts/fireworks/models/qwen3p6-plus",
|
||||
"fireworks:accounts/fireworks/models/qwen3p7-plus",
|
||||
"google_genai:gemini-3-flash-preview",
|
||||
"google_genai:gemini-3.5-flash",
|
||||
"google_genai:gemini-3.1-pro-preview",
|
||||
"ollama:deepseek-v4-flash:cloud",
|
||||
"ollama:deepseek-v4-pro:cloud",
|
||||
"ollama:glm-5.1:cloud",
|
||||
"ollama:kimi-k2.6:cloud",
|
||||
"ollama:minimax-m2.7:cloud",
|
||||
"ollama:glm-5.2:cloud",
|
||||
"ollama:kimi-k2.7-code:cloud",
|
||||
"ollama:minimax-m3:cloud",
|
||||
"openai:gpt-5.4",
|
||||
"openai:gpt-5.4-mini",
|
||||
"openai:gpt-5.4-pro",
|
||||
@@ -95,10 +105,9 @@ _RECOMMENDED_MODELS: frozenset[str] = frozenset(
|
||||
"openrouter:deepseek/deepseek-v4-flash",
|
||||
"openrouter:deepseek/deepseek-v4-flash:free",
|
||||
"openrouter:deepseek/deepseek-v4-pro",
|
||||
"openrouter:google/gemini-3-flash-preview",
|
||||
"openrouter:google/gemini-3.5-flash",
|
||||
"openrouter:google/gemini-3.1-pro-preview",
|
||||
"openrouter:minimax/minimax-m2.7",
|
||||
"openrouter:moonshotai/kimi-k2.6",
|
||||
"openrouter:moonshotai/kimi-k2.7-code",
|
||||
"openrouter:openai/gpt-5.4",
|
||||
"openrouter:openai/gpt-5.4-mini",
|
||||
@@ -107,8 +116,6 @@ _RECOMMENDED_MODELS: frozenset[str] = frozenset(
|
||||
"openrouter:openai/gpt-5.5-pro",
|
||||
"openrouter:openrouter/fusion",
|
||||
"openrouter:qwen/qwen3.7-plus",
|
||||
"openrouter:z-ai/glm-5",
|
||||
"openrouter:z-ai/glm-5.1",
|
||||
"openrouter:z-ai/glm-5.2",
|
||||
}
|
||||
)
|
||||
@@ -116,9 +123,9 @@ _RECOMMENDED_MODELS: frozenset[str] = frozenset(
|
||||
|
||||
Used by the onboarding picker (`curated=True`) and by the in-`/model`
|
||||
"Recommended only" toggle (Ctrl+R). Same model IDs may appear under multiple
|
||||
providers (e.g. Kimi-K2.6 via `baseten`, `ollama`, and `openrouter`) and are
|
||||
listed under each provider intentionally so the user can pick whichever
|
||||
provider they have credentials for.
|
||||
providers (e.g. Kimi-K2.7-Code via `baseten`, `fireworks`, `ollama`, and
|
||||
`openrouter`) and are listed under each provider intentionally so the user
|
||||
can pick whichever provider they have credentials for.
|
||||
"""
|
||||
|
||||
|
||||
@@ -251,9 +258,9 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
}
|
||||
|
||||
ModelSelectorScreen > Vertical {
|
||||
width: 80;
|
||||
width: 76;
|
||||
max-width: 90%;
|
||||
height: 80%;
|
||||
height: auto;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
padding: 1 2;
|
||||
@@ -288,8 +295,9 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
}
|
||||
|
||||
ModelSelectorScreen .model-list {
|
||||
height: 1fr;
|
||||
min-height: 5;
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
max-height: 16; /* keep in sync with `_MODEL_LIST_MAX_HEIGHT` */
|
||||
scrollbar-gutter: stable;
|
||||
background: $background;
|
||||
}
|
||||
@@ -441,9 +449,8 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
def _help_text(self) -> str:
|
||||
"""Build the footer help text.
|
||||
|
||||
Curated/onboarding mode omits the Ctrl+R toggle and the Esc hint;
|
||||
Escape stays bound but is not advertised. Standard mode shows
|
||||
"Esc cancel".
|
||||
Curated/onboarding mode omits the Ctrl+S and Ctrl+R hints. Escape stays
|
||||
bound but is not advertised so the footer does not wrap awkwardly.
|
||||
|
||||
Returns:
|
||||
The bullet-separated help line.
|
||||
@@ -451,11 +458,11 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
glyphs = get_glyphs()
|
||||
parts = [
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate",
|
||||
"Tab autocomplete",
|
||||
"Enter select",
|
||||
"Ctrl+S set default",
|
||||
]
|
||||
if not self._curated:
|
||||
parts.extend(("Ctrl+R recommended", "Esc cancel"))
|
||||
parts.extend(("Ctrl+S set default", "Ctrl+R recommended"))
|
||||
sep = f" {glyphs.bullet} "
|
||||
return sep.join(parts)
|
||||
|
||||
@@ -539,8 +546,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
integration isn't installed, added as greyed-out
|
||||
install-required rows; (2) the provider is installed but its
|
||||
upstream profiles omit the model, added as normal selectable
|
||||
rows. Onboarding sets this `False` because it has a dedicated
|
||||
dependency-install step.
|
||||
rows.
|
||||
|
||||
Returns:
|
||||
A `_ModelData` bundle of the discovered models, default spec,
|
||||
@@ -566,6 +572,8 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
# frozenset iterated once), so this entry guard is the only dedup
|
||||
# needed and the set never has to grow inside the loop.
|
||||
existing_specs = {spec for spec, _ in all_models}
|
||||
installed_recommended: list[tuple[str, str]] = []
|
||||
uninstalled_recommended: list[tuple[str, str]] = []
|
||||
for spec in sorted(_RECOMMENDED_MODELS):
|
||||
if spec in existing_specs:
|
||||
continue
|
||||
@@ -594,12 +602,14 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
# or filtered out). Add it as a normal selectable row so the
|
||||
# hardcoded recommendation isn't silently dropped when the
|
||||
# profile list lags.
|
||||
all_models.append((spec, provider))
|
||||
installed_recommended.append((spec, provider))
|
||||
continue
|
||||
if extra is None or provider_installed:
|
||||
continue
|
||||
install_extras[provider] = extra
|
||||
all_models.append((spec, provider))
|
||||
uninstalled_recommended.append((spec, provider))
|
||||
all_models.extend(installed_recommended)
|
||||
all_models.extend(uninstalled_recommended)
|
||||
|
||||
profiles = get_model_profiles(cli_override=cli_override)
|
||||
recent_specs = load_recent_models()
|
||||
@@ -678,6 +688,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
colors = theme.get_theme_colors(self)
|
||||
container = self.query_one(Vertical)
|
||||
container.styles.border = ("ascii", colors.success)
|
||||
self.call_after_refresh(self._fit_model_list)
|
||||
|
||||
# Focus the filter input immediately so the user can start typing
|
||||
# while model data loads.
|
||||
@@ -689,7 +700,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
data = await asyncio.to_thread(
|
||||
self._load_model_data,
|
||||
self._cli_profile_override,
|
||||
include_uninstalled=not self._curated,
|
||||
include_uninstalled=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to load model data for /model selector")
|
||||
@@ -727,6 +738,40 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
await self._update_display()
|
||||
self._update_footer()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Refit the model list when terminal dimensions change."""
|
||||
self.call_after_refresh(self._fit_model_list)
|
||||
|
||||
def _fit_model_list(self) -> None:
|
||||
"""Cap the model list so modal controls stay visible."""
|
||||
try:
|
||||
container = self.query_one(Vertical)
|
||||
except NoMatches:
|
||||
# This runs deferred via `call_after_refresh`/`on_resize`; the
|
||||
# screen may have been popped before it fires (e.g. a resize racing
|
||||
# dismissal). Sizing is cosmetic, so skip quietly but leave a
|
||||
# breadcrumb rather than letting it surface in the event loop.
|
||||
logger.debug(
|
||||
"Skipping model-list refit; screen not mounted",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
# The screen is still mounted, so `.model-list` (always composed) must
|
||||
# exist; a missing body here is a structural regression, not the
|
||||
# teardown race, so let `NoMatches` surface rather than silently
|
||||
# rendering an uncapped list.
|
||||
body = self.query_one(".model-list", VerticalScroll)
|
||||
non_body_height = max(0, container.region.height - body.region.height)
|
||||
available_height = self.size.height - non_body_height
|
||||
max_height = max(
|
||||
_MODEL_LIST_MIN_HEIGHT,
|
||||
min(_MODEL_LIST_MAX_HEIGHT, available_height),
|
||||
)
|
||||
current = body.styles.max_height
|
||||
if current is not None and current.cells == max_height:
|
||||
return
|
||||
body.styles.max_height = max_height
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Filter models as user types.
|
||||
|
||||
@@ -887,6 +932,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
empty_content = Content.styled("No matching models", "dim")
|
||||
await self._options_container.mount(Static(empty_content))
|
||||
self._update_footer()
|
||||
self.call_after_refresh(self._fit_model_list)
|
||||
return
|
||||
|
||||
has_filter = bool(self._filter_text.strip())
|
||||
@@ -1056,6 +1102,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
selected_widget.scroll_visible(animate=False)
|
||||
|
||||
self._update_footer()
|
||||
self.call_after_refresh(self._fit_model_list)
|
||||
|
||||
@staticmethod
|
||||
def _format_auth_indicator(
|
||||
@@ -1466,18 +1513,20 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
self._dismiss_with_result((model_spec, provider))
|
||||
return
|
||||
|
||||
# Onboarding (`_curated`) runs its own dependency-install step and never
|
||||
# surfaces uninstalled providers, so skip install routing there.
|
||||
if not self._curated:
|
||||
from deepagents_code.config_manifest import (
|
||||
is_provider_package_installed,
|
||||
provider_install_extra,
|
||||
)
|
||||
from deepagents_code.config_manifest import (
|
||||
is_provider_package_installed,
|
||||
provider_install_extra,
|
||||
)
|
||||
|
||||
extra = provider_install_extra(provider)
|
||||
if extra is not None and not is_provider_package_installed(provider):
|
||||
self._prompt_install_provider(model_spec, provider, extra)
|
||||
extra = provider_install_extra(provider)
|
||||
if extra is not None and not is_provider_package_installed(provider):
|
||||
if self._curated:
|
||||
# Onboarding installs first, then prompts for credentials from the
|
||||
# launch flow, matching the dependency screen's auto-install copy.
|
||||
self._dismiss_with_result((model_spec, provider))
|
||||
return
|
||||
self._prompt_install_provider(model_spec, provider, extra)
|
||||
return
|
||||
|
||||
status = get_provider_auth_status(provider)
|
||||
if not status.blocks_start:
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, cast
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.color import Color as TColor
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
@@ -22,7 +23,12 @@ from textual.style import Style as TStyle
|
||||
from textual.widgets import Checkbox, Input, Select, Static
|
||||
|
||||
# Specialize focused Select overlay key handling; no public re-export available.
|
||||
from textual.widgets._select import SelectCurrent, SelectOverlay # noqa: PLC2701
|
||||
from textual.widgets._select import ( # noqa: PLC2701
|
||||
NoSelection,
|
||||
Option,
|
||||
SelectCurrent,
|
||||
SelectOverlay,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
@@ -593,6 +599,45 @@ class ThreadScopeSelect(Select[str]):
|
||||
compact=Select.compact
|
||||
)
|
||||
|
||||
def _setup_options_renderables(self) -> None:
|
||||
"""Populate the custom overlay when options change."""
|
||||
options = [
|
||||
Option(Text(self.prompt, style="dim"))
|
||||
if value == self.NULL
|
||||
else Option(prompt)
|
||||
for prompt, value in self._options
|
||||
]
|
||||
|
||||
try:
|
||||
option_list = self.query_one(ThreadScopeSelectOverlay)
|
||||
except NoMatches:
|
||||
if self.is_attached:
|
||||
self.call_after_refresh(self._setup_options_renderables)
|
||||
return
|
||||
|
||||
option_list.clear_options()
|
||||
option_list.add_options(options)
|
||||
|
||||
def _watch_value(self, value: str | NoSelection) -> None:
|
||||
"""Update the current value while using the custom overlay widget."""
|
||||
self._value = value
|
||||
try:
|
||||
select_current = self.query_one(SelectCurrent)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
if value == self.NULL:
|
||||
select_current.update(self.NULL)
|
||||
else:
|
||||
for index, (prompt, option_value) in enumerate(self._options):
|
||||
if option_value == value:
|
||||
with contextlib.suppress(NoMatches):
|
||||
select_overlay = self.query_one(ThreadScopeSelectOverlay)
|
||||
select_overlay.highlighted = index
|
||||
select_current.update(prompt)
|
||||
break
|
||||
self.post_message(self.Changed(self, value))
|
||||
|
||||
def key_tab(self, event: Key) -> None:
|
||||
"""Prevent focus traversal while the dropdown menu is open."""
|
||||
if self.expanded:
|
||||
|
||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.app import App, ComposeResult, ScreenStackError
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container
|
||||
from textual.content import Content
|
||||
@@ -47,7 +47,10 @@ from deepagents_code.app import (
|
||||
)
|
||||
from deepagents_code.event_bus import ExternalEvent
|
||||
from deepagents_code.widgets.chat_input import ChatInput
|
||||
from deepagents_code.widgets.launch_init import LaunchNameScreen
|
||||
from deepagents_code.widgets.launch_init import (
|
||||
LaunchDependenciesScreen,
|
||||
LaunchNameScreen,
|
||||
)
|
||||
from deepagents_code.widgets.messages import (
|
||||
AppMessage,
|
||||
ErrorMessage,
|
||||
@@ -925,6 +928,169 @@ class TestStartupSequence:
|
||||
mount_message_mock.assert_awaited_once()
|
||||
assert events == ["switch:openai:gpt-5", "mark", "welcome"]
|
||||
|
||||
async def test_launch_init_installs_missing_selected_provider(self) -> None:
|
||||
"""Onboarding installs a missing recommended provider before switching."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
|
||||
app._install_extra_then_switch = AsyncMock() # ty: ignore
|
||||
app._switch_model = AsyncMock() # ty: ignore
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config_manifest.provider_install_extra",
|
||||
return_value="baseten",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.config_manifest.is_provider_package_installed",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
await app._switch_or_install_launch_model(
|
||||
"baseten:zai-org/GLM-5.2",
|
||||
"baseten",
|
||||
)
|
||||
|
||||
app._install_extra_then_switch.assert_awaited_once_with( # ty: ignore
|
||||
"baseten",
|
||||
"baseten:zai-org/GLM-5.2",
|
||||
)
|
||||
app._switch_model.assert_not_awaited() # ty: ignore
|
||||
|
||||
async def test_launch_init_switches_installed_selected_provider(self) -> None:
|
||||
"""Onboarding switches directly when the selected provider is installed."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
|
||||
app._install_extra_then_switch = AsyncMock() # ty: ignore
|
||||
app._switch_model = AsyncMock() # ty: ignore
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config_manifest.provider_install_extra",
|
||||
return_value="baseten",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.config_manifest.is_provider_package_installed",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
await app._switch_or_install_launch_model(
|
||||
"baseten:zai-org/GLM-5.2",
|
||||
"baseten",
|
||||
)
|
||||
|
||||
app._install_extra_then_switch.assert_not_awaited() # ty: ignore
|
||||
app._switch_model.assert_awaited_once_with( # ty: ignore
|
||||
"baseten:zai-org/GLM-5.2",
|
||||
announce_unchanged=False,
|
||||
)
|
||||
|
||||
async def test_launch_init_consumes_injected_dependency_result(self) -> None:
|
||||
"""The mount path's pre-wired dependency future drives the model switch.
|
||||
|
||||
On mount, `on_mount` switches the name screen directly into the
|
||||
dependency screen and hands `_run_launch_init_sequence` the already-wired
|
||||
result future, so the sequence must consume that future instead of
|
||||
re-prompting via `_prompt_launch_dependencies_then_model`.
|
||||
"""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._write_launch_name_memory = AsyncMock() # ty: ignore
|
||||
switch_or_install = AsyncMock()
|
||||
app._switch_or_install_launch_model = switch_or_install # ty: ignore
|
||||
# The fallback prompt must NOT run when a result is injected.
|
||||
prompt_flow_mock = AsyncMock()
|
||||
app._prompt_launch_dependencies_then_model = prompt_flow_mock # ty: ignore
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
name_result: asyncio.Future[str | None] = loop.create_future()
|
||||
name_result.set_result("Ada")
|
||||
dependency_result: asyncio.Future[tuple[bool, tuple[str, str] | None]] = (
|
||||
loop.create_future()
|
||||
)
|
||||
dependency_result.set_result((True, ("openai:gpt-5.4", "openai")))
|
||||
|
||||
with patch(
|
||||
"deepagents_code.onboarding.mark_onboarding_complete",
|
||||
return_value=True,
|
||||
) as mark_complete:
|
||||
await app._run_launch_init_sequence(
|
||||
name_result=name_result,
|
||||
dependency_result=dependency_result,
|
||||
)
|
||||
|
||||
prompt_flow_mock.assert_not_awaited()
|
||||
switch_or_install.assert_awaited_once_with("openai:gpt-5.4", "openai")
|
||||
mark_complete.assert_called_once_with()
|
||||
|
||||
async def test_launch_init_wires_name_screen_to_dependency_screen(self) -> None:
|
||||
"""On mount, submitting the name switches straight into the deps screen.
|
||||
|
||||
Regression guard for the no-flash wiring: the mount path must set the
|
||||
name screen's `continue_screen` and pass a `dependency_result` future. If
|
||||
it regressed to the old `LaunchNameScreen()` with no continue screen,
|
||||
onboarding would fall back to the double-modal flow and the post-submit
|
||||
screen would not be the dependency summary.
|
||||
"""
|
||||
app = DeepAgentsApp(launch_init=True)
|
||||
app._prewarm_deferred_imports = MagicMock() # ty: ignore
|
||||
app._resolve_git_branch_and_continue = AsyncMock() # ty: ignore
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, LaunchNameScreen)
|
||||
# The name screen is pre-wired to continue into the dependency
|
||||
# summary rather than dismissing back to the base app.
|
||||
assert isinstance(
|
||||
app.screen._continue_screen, # ty: ignore
|
||||
LaunchDependenciesScreen,
|
||||
)
|
||||
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, LaunchDependenciesScreen)
|
||||
|
||||
launch_task = app._launch_init_task
|
||||
assert launch_task is not None
|
||||
app.screen.action_cancel()
|
||||
await asyncio.wait_for(launch_task, timeout=2)
|
||||
await pilot.pause()
|
||||
|
||||
async def test_launch_init_finishes_when_dependency_switch_fails(self) -> None:
|
||||
"""A failed name-to-dependencies switch should skip the rest of setup."""
|
||||
app = DeepAgentsApp(launch_init=True)
|
||||
app._prewarm_deferred_imports = MagicMock() # ty: ignore
|
||||
app._resolve_git_branch_and_continue = AsyncMock() # ty: ignore
|
||||
app._mark_onboarding_complete = AsyncMock() # ty: ignore
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._write_launch_name_memory = AsyncMock() # ty: ignore
|
||||
switch_or_install = AsyncMock()
|
||||
app._switch_or_install_launch_model = switch_or_install # ty: ignore
|
||||
original_switch_screen = app.switch_screen
|
||||
|
||||
def fail_dependency_switch(screen: ModalScreen[Any] | str) -> None:
|
||||
if isinstance(screen, LaunchDependenciesScreen):
|
||||
msg = "stack torn down"
|
||||
raise ScreenStackError(msg)
|
||||
original_switch_screen(screen)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, LaunchNameScreen)
|
||||
launch_task = app._launch_init_task
|
||||
assert launch_task is not None
|
||||
app.switch_screen = fail_dependency_switch # ty: ignore
|
||||
|
||||
await pilot.press("a", "d", "a", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
await asyncio.wait_for(launch_task, timeout=2)
|
||||
await pilot.pause()
|
||||
|
||||
app._write_launch_name_memory.assert_awaited_once_with("Ada") # ty: ignore
|
||||
app._mark_onboarding_complete.assert_awaited_once() # ty: ignore
|
||||
switch_or_install.assert_not_awaited()
|
||||
|
||||
async def test_launch_init_sequence_allows_empty_name(self) -> None:
|
||||
"""Onboarding setup should continue to model selection without a name."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
|
||||
@@ -7481,7 +7647,7 @@ class TestInstallExtraModelSwitch:
|
||||
|
||||
await app._install_extra_then_switch(
|
||||
"baseten",
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
extra_kwargs={"temperature": 0},
|
||||
)
|
||||
|
||||
@@ -7490,7 +7656,7 @@ class TestInstallExtraModelSwitch:
|
||||
screen = app._push_screen_wait.await_args.args[0] # ty: ignore
|
||||
assert isinstance(screen, AuthPromptScreen)
|
||||
dispatch.assert_called_once_with(
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
extra_kwargs={"temperature": 0},
|
||||
)
|
||||
|
||||
@@ -7506,7 +7672,7 @@ class TestInstallExtraModelSwitch:
|
||||
|
||||
await app._install_extra_then_switch(
|
||||
"baseten",
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
)
|
||||
|
||||
app._install_extra.assert_awaited_once_with("baseten", auto_restart=True) # ty: ignore
|
||||
@@ -7537,7 +7703,7 @@ class TestInstallExtraModelSwitch:
|
||||
|
||||
await app._install_extra_then_switch(
|
||||
"baseten",
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
)
|
||||
|
||||
app._push_screen_wait.assert_awaited_once() # ty: ignore
|
||||
@@ -7811,7 +7977,7 @@ class TestHandleModelSelection:
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
("baseten:moonshotai/Kimi-K2.7-Code", "baseten"),
|
||||
extra_kwargs={"temperature": 0},
|
||||
)
|
||||
|
||||
@@ -7829,7 +7995,7 @@ class TestHandleModelSelection:
|
||||
await run_worker.call_args.args[0]
|
||||
install.assert_awaited_once_with(
|
||||
"baseten",
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
extra_kwargs={"temperature": 0},
|
||||
)
|
||||
assert app._model_install_switching is False
|
||||
@@ -7851,7 +8017,7 @@ class TestHandleModelSelection:
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
("baseten:moonshotai/Kimi-K2.7-Code", "baseten"),
|
||||
)
|
||||
|
||||
assert app._model_install_switching is True
|
||||
@@ -7883,7 +8049,7 @@ class TestHandleModelSelection:
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
("baseten:moonshotai/Kimi-K2.7-Code", "baseten"),
|
||||
)
|
||||
|
||||
assert app._model_install_switching is True
|
||||
@@ -7912,7 +8078,7 @@ class TestHandleModelSelection:
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
("baseten:moonshotai/Kimi-K2.7-Code", "baseten"),
|
||||
)
|
||||
|
||||
app.notify.assert_called_once()
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestInstallProviderConfirmScreen:
|
||||
|
||||
app.push_screen(
|
||||
InstallProviderConfirmScreen(
|
||||
"baseten", "baseten", "baseten:moonshotai/Kimi-K2.6"
|
||||
"baseten", "baseten", "baseten:moonshotai/Kimi-K2.7-Code"
|
||||
),
|
||||
outcomes.append,
|
||||
)
|
||||
@@ -120,7 +120,7 @@ class TestInstallProviderConfirmScreen:
|
||||
|
||||
app.push_screen(
|
||||
InstallProviderConfirmScreen(
|
||||
"baseten", "baseten", "baseten:moonshotai/Kimi-K2.6"
|
||||
"baseten", "baseten", "baseten:moonshotai/Kimi-K2.7-Code"
|
||||
),
|
||||
outcomes.append,
|
||||
)
|
||||
@@ -136,7 +136,7 @@ class TestInstallProviderConfirmScreen:
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(
|
||||
InstallProviderConfirmScreen(
|
||||
"baseten", "baseten", "baseten:moonshotai/Kimi-K2.6"
|
||||
"baseten", "baseten", "baseten:moonshotai/Kimi-K2.7-Code"
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
@@ -144,7 +144,7 @@ class TestInstallProviderConfirmScreen:
|
||||
bodies = app.screen.query(".install-confirm-body")
|
||||
assert len(bodies) == 1
|
||||
rendered = str(bodies.first().render())
|
||||
assert "baseten:moonshotai/Kimi-K2.6" in rendered
|
||||
assert "baseten:moonshotai/Kimi-K2.7-Code" in rendered
|
||||
assert "baseten" in rendered
|
||||
|
||||
async def test_renders_add_key_body_without_model_spec(self) -> None:
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult, ScreenStackError
|
||||
from textual.containers import Container
|
||||
from textual.containers import Container, Vertical, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from deepagents_code.extras_info import ExtraDependencyStatus
|
||||
from deepagents_code.config import get_glyphs
|
||||
from deepagents_code.extras_info import (
|
||||
MODEL_PROVIDER_EXTRAS,
|
||||
SANDBOX_EXTRAS,
|
||||
STANDALONE_EXTRAS,
|
||||
ExtraDependencyStatus,
|
||||
)
|
||||
from deepagents_code.widgets.launch_init import (
|
||||
LaunchDependenciesScreen,
|
||||
LaunchNameScreen,
|
||||
@@ -138,6 +145,63 @@ class TestLaunchNameScreen:
|
||||
assert app.dismissed is True
|
||||
assert app.result == ""
|
||||
|
||||
async def test_submit_can_switch_directly_to_next_screen(self) -> None:
|
||||
"""Submitting can replace the modal without exposing the base screen."""
|
||||
app = LaunchNameTestApp()
|
||||
continued: list[str] = []
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(
|
||||
LaunchNameScreen(
|
||||
continue_screen=DummyNextScreen(),
|
||||
on_continue=continued.append,
|
||||
),
|
||||
lambda result: setattr(app, "result", result),
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("a", "d", "a", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, DummyNextScreen)
|
||||
|
||||
assert continued == ["Ada"]
|
||||
assert app.result is None
|
||||
|
||||
async def test_submit_switch_failure_dismisses_with_typed_name(self) -> None:
|
||||
"""A `ScreenStackError` during switch should dismiss with the typed name.
|
||||
|
||||
Unlike `LaunchDependenciesScreen`, the name screen emits no toast on this
|
||||
path: the name has already propagated via `on_continue` before the switch
|
||||
is attempted, so the fallback only needs to dismiss without dropping it.
|
||||
"""
|
||||
app = LaunchNameTestApp()
|
||||
continued: list[str] = []
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(
|
||||
LaunchNameScreen(
|
||||
continue_screen=DummyNextScreen(),
|
||||
on_continue=continued.append,
|
||||
),
|
||||
lambda result: setattr(app, "result", result),
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
def fake_switch_screen(_screen: object) -> None:
|
||||
msg = "stack torn down"
|
||||
raise ScreenStackError(msg)
|
||||
|
||||
app.switch_screen = fake_switch_screen # ty: ignore
|
||||
|
||||
await pilot.press("a", "d", "a", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
# `on_continue` fired before the failed switch, so the name propagated.
|
||||
assert continued == ["Ada"]
|
||||
# The fallback dismisses with the typed name rather than dropping it.
|
||||
assert app.result == "Ada"
|
||||
|
||||
async def test_escape_skips(self) -> None:
|
||||
"""Escape should skip the setup flow."""
|
||||
app = LaunchNameTestApp()
|
||||
@@ -189,16 +253,174 @@ class TestLaunchDependenciesScreen:
|
||||
str(widget.content) for widget in app.screen.query(Static)
|
||||
)
|
||||
|
||||
glyphs = get_glyphs()
|
||||
assert "Installed Integrations" in content
|
||||
assert "Ready now" in content
|
||||
assert "Model providers: anthropic" in content
|
||||
assert "Sandboxes: daytona" in content
|
||||
assert "Available to add" in content
|
||||
assert "Model providers: bedrock" in content
|
||||
assert "Sandboxes: runloop" in content
|
||||
# Section titles carry a total count; the `(2)` suffix is distinctive
|
||||
# enough to prove the section header rendered (vs. matching the intro
|
||||
# copy, which also mentions "model providers and sandboxes").
|
||||
assert "Ready now (2)" in content
|
||||
assert "Available to add (2)" in content
|
||||
# Ready extras carry the checkmark glyph; addable ones the empty circle.
|
||||
assert f"{glyphs.checkmark} anthropic" in content
|
||||
assert f"{glyphs.checkmark} daytona" in content
|
||||
assert f"{glyphs.circle_empty} bedrock" in content
|
||||
assert f"{glyphs.circle_empty} runloop" in content
|
||||
# The screen points at how to act on the listed integrations.
|
||||
assert "/install" in content
|
||||
assert "Enter to continue" in content
|
||||
assert "Esc skip setup" not in content
|
||||
|
||||
async def test_available_section_is_not_truncated(self) -> None:
|
||||
"""Every addable extra is listed, with no "+N more" summary."""
|
||||
statuses = tuple(
|
||||
ExtraDependencyStatus(
|
||||
name=name, installed=(), missing=(f"langchain-{name}",)
|
||||
)
|
||||
for name in sorted(MODEL_PROVIDER_EXTRAS)
|
||||
)
|
||||
app = LaunchNameTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_dependencies_screen(statuses)
|
||||
await pilot.pause()
|
||||
|
||||
content = "\n".join(
|
||||
str(widget.content) for widget in app.screen.query(Static)
|
||||
)
|
||||
|
||||
# No "+N more" truncation summary (the old `_EXTRA_LIST_LIMIT` cap).
|
||||
assert re.search(r"\+\d+ more", content) is None
|
||||
for name in MODEL_PROVIDER_EXTRAS:
|
||||
assert name in content
|
||||
|
||||
async def test_populated_screen_fits_standard_terminal_height(self) -> None:
|
||||
"""A full dependency list should keep footer controls visible at 80x24."""
|
||||
statuses = tuple(
|
||||
ExtraDependencyStatus(
|
||||
name=name, installed=(), missing=(f"langchain-{name}",)
|
||||
)
|
||||
for name in sorted(
|
||||
MODEL_PROVIDER_EXTRAS | SANDBOX_EXTRAS | STANDALONE_EXTRAS
|
||||
)
|
||||
)
|
||||
app = LaunchNameTestApp()
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
app.show_dependencies_screen(statuses)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
container = app.screen.query_one(Vertical)
|
||||
body = app.screen.query_one("#launch-dependencies-body", VerticalScroll)
|
||||
help_text = app.screen.query_one(".launch-init-help", Static)
|
||||
|
||||
assert container.region.y >= 0
|
||||
assert container.region.y + container.region.height <= app.size.height
|
||||
assert help_text.region.y + help_text.region.height <= app.size.height
|
||||
max_height = body.styles.max_height
|
||||
assert max_height is not None
|
||||
assert max_height.cells is not None
|
||||
assert max_height.cells < 16
|
||||
|
||||
async def test_renders_other_category(self) -> None:
|
||||
"""Standalone extras (e.g. quickjs) get their own category."""
|
||||
statuses = (
|
||||
ExtraDependencyStatus(
|
||||
name="quickjs", installed=(), missing=("langchain-quickjs",)
|
||||
),
|
||||
)
|
||||
app = LaunchNameTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_dependencies_screen(statuses)
|
||||
await pilot.pause()
|
||||
|
||||
content = "\n".join(
|
||||
str(widget.content) for widget in app.screen.query(Static)
|
||||
)
|
||||
|
||||
assert "Other" in content
|
||||
assert "quickjs" in content
|
||||
|
||||
async def test_empty_ready_section_shows_placeholder(self) -> None:
|
||||
"""When nothing is installed, "Ready now" shows its placeholder."""
|
||||
statuses = tuple(
|
||||
ExtraDependencyStatus(
|
||||
name=name, installed=(), missing=(f"langchain-{name}",)
|
||||
)
|
||||
for name in sorted(MODEL_PROVIDER_EXTRAS)
|
||||
)
|
||||
app = LaunchNameTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_dependencies_screen(statuses)
|
||||
await pilot.pause()
|
||||
|
||||
content = "\n".join(
|
||||
str(widget.content) for widget in app.screen.query(Static)
|
||||
)
|
||||
|
||||
assert "Ready now (0)" in content
|
||||
assert "Nothing installed yet" in content
|
||||
# No extra is ready, so the checkmark glyph never appears.
|
||||
assert get_glyphs().checkmark not in content
|
||||
|
||||
async def test_empty_available_section_shows_placeholder(self) -> None:
|
||||
"""When everything is installed, "Available to add" shows its placeholder."""
|
||||
statuses = tuple(
|
||||
ExtraDependencyStatus(
|
||||
name=name,
|
||||
installed=((f"langchain-{name}", "1.0.0"),),
|
||||
missing=(),
|
||||
)
|
||||
for name in sorted(MODEL_PROVIDER_EXTRAS)
|
||||
)
|
||||
app = LaunchNameTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_dependencies_screen(statuses)
|
||||
await pilot.pause()
|
||||
|
||||
content = "\n".join(
|
||||
str(widget.content) for widget in app.screen.query(Static)
|
||||
)
|
||||
|
||||
assert "Available to add (0)" in content
|
||||
assert "All bundled integrations are installed." in content
|
||||
# Nothing is addable, so the empty-circle glyph never appears.
|
||||
assert get_glyphs().circle_empty not in content
|
||||
|
||||
async def test_resize_shrinks_body_to_keep_footer_visible(self) -> None:
|
||||
"""Shrinking the terminal refits the body so the footer stays visible."""
|
||||
statuses = tuple(
|
||||
ExtraDependencyStatus(
|
||||
name=name, installed=(), missing=(f"langchain-{name}",)
|
||||
)
|
||||
for name in sorted(
|
||||
MODEL_PROVIDER_EXTRAS | SANDBOX_EXTRAS | STANDALONE_EXTRAS
|
||||
)
|
||||
)
|
||||
app = LaunchNameTestApp()
|
||||
async with app.run_test(size=(80, 40)) as pilot:
|
||||
app.show_dependencies_screen(statuses)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# A tall terminal leaves room for the full cap.
|
||||
tall = app.screen.query_one(
|
||||
"#launch-dependencies-body", VerticalScroll
|
||||
).styles.max_height
|
||||
assert tall is not None
|
||||
assert tall.cells == 16
|
||||
|
||||
await pilot.resize_terminal(80, 16)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
body = app.screen.query_one("#launch-dependencies-body", VerticalScroll)
|
||||
help_text = app.screen.query_one(".launch-init-help", Static)
|
||||
short = body.styles.max_height
|
||||
|
||||
assert short is not None
|
||||
assert short.cells is not None
|
||||
assert short.cells < 16
|
||||
assert help_text.region.y + help_text.region.height <= app.size.height
|
||||
|
||||
async def test_enter_continues(self) -> None:
|
||||
"""Enter should continue to the next onboarding step."""
|
||||
app = LaunchNameTestApp()
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container
|
||||
from textual.containers import Container, Vertical, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
@@ -249,11 +250,47 @@ class TestModelSelectorChrome:
|
||||
|
||||
help_text = screen.query_one(".model-selector-help", Static)
|
||||
|
||||
assert "Tab autocomplete" in str(help_text.content)
|
||||
assert "Esc skip setup" not in str(help_text.content)
|
||||
assert "Esc cancel" not in str(help_text.content)
|
||||
|
||||
async def test_standard_selector_help_uses_cancel(self) -> None:
|
||||
"""The regular /model selector should keep cancel wording."""
|
||||
async def test_curated_selector_help_hides_default_hint(self) -> None:
|
||||
"""Onboarding model selection should not advertise default changes."""
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
screen = ModelSelectorScreen(curated=True)
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
help_text = screen.query_one(".model-selector-help", Static)
|
||||
|
||||
assert "Ctrl+S" not in str(help_text.content)
|
||||
assert "set default" not in str(help_text.content)
|
||||
|
||||
@pytest.mark.parametrize("curated", [False, True])
|
||||
async def test_selector_uses_compact_sizing(self, *, curated: bool) -> None:
|
||||
"""Model selection should size like the integration summary."""
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
screen = ModelSelectorScreen(curated=curated)
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
container = screen.query_one(Vertical)
|
||||
body = screen.query_one(".model-list", VerticalScroll)
|
||||
help_text = screen.query_one(".model-selector-help", Static)
|
||||
|
||||
assert container.region.y >= 0
|
||||
assert container.region.y + container.region.height <= app.size.height
|
||||
assert help_text.region.y + help_text.region.height <= app.size.height
|
||||
max_height = body.styles.max_height
|
||||
assert max_height is not None
|
||||
assert max_height.cells is not None
|
||||
assert max_height.cells <= 16
|
||||
|
||||
async def test_standard_selector_help_hides_cancel_hint(self) -> None:
|
||||
"""The regular /model selector should not leave a trailing separator."""
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
screen = ModelSelectorScreen()
|
||||
@@ -262,7 +299,11 @@ class TestModelSelectorChrome:
|
||||
|
||||
help_text = screen.query_one(".model-selector-help", Static)
|
||||
|
||||
assert "Esc cancel" in str(help_text.content)
|
||||
assert "Tab autocomplete" in str(help_text.content)
|
||||
# Standard mode still advertises the default-setting shortcut that
|
||||
# curated/onboarding mode hides.
|
||||
assert "Ctrl+S set default" in str(help_text.content)
|
||||
assert "Esc cancel" not in str(help_text.content)
|
||||
|
||||
|
||||
class TestRecommendedToggle:
|
||||
@@ -1807,6 +1848,47 @@ class TestModelSelectorAuthGate:
|
||||
class TestModelSelectorInstallRouting:
|
||||
"""Selecting a model whose provider is not installed prompts to install."""
|
||||
|
||||
async def test_curated_screen_loads_uninstalled_recommended(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Onboarding includes install-required recommended models."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
captured: dict[str, bool] = {}
|
||||
|
||||
def load_model_data(
|
||||
_cli_override: dict[str, Any] | None,
|
||||
*,
|
||||
include_uninstalled: bool = True,
|
||||
) -> model_selector._ModelData:
|
||||
captured["include_uninstalled"] = include_uninstalled
|
||||
return model_selector._ModelData(
|
||||
[("baseten:zai-org/GLM-5.2", "baseten")],
|
||||
None,
|
||||
{},
|
||||
[],
|
||||
{"baseten": "baseten"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ModelSelectorScreen,
|
||||
"_load_model_data",
|
||||
staticmethod(load_model_data),
|
||||
)
|
||||
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(
|
||||
ModelSelectorScreen(
|
||||
current_model="openai:gpt-5.5",
|
||||
current_provider="openai",
|
||||
curated=True,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert captured["include_uninstalled"] is True
|
||||
|
||||
async def test_load_model_data_surfaces_uninstalled_recommended(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1833,6 +1915,38 @@ class TestModelSelectorInstallRouting:
|
||||
assert install_extras.get("baseten") == "baseten"
|
||||
assert install_extras.get("ollama") == "ollama"
|
||||
|
||||
async def test_load_model_data_orders_installed_recommended_before_uninstalled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Installed-provider recommendations sort before install-required rows."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
installed_spec = "ollama:glm-5.2:cloud"
|
||||
uninstalled_spec = "fireworks:accounts/fireworks/models/deepseek-v4-pro"
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"_RECOMMENDED_MODELS",
|
||||
frozenset({installed_spec, uninstalled_spec}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"get_available_models",
|
||||
lambda: {"ollama": ["local-model"]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"importlib.util.find_spec",
|
||||
lambda package: object() if package == "langchain_ollama" else None,
|
||||
)
|
||||
|
||||
all_models, _default, _profiles, _recent, install_extras = (
|
||||
ModelSelectorScreen._load_model_data(None, include_uninstalled=True)
|
||||
)
|
||||
|
||||
specs = [model_spec for model_spec, _ in all_models]
|
||||
assert specs.index(installed_spec) < specs.index(uninstalled_spec)
|
||||
assert install_extras.get("fireworks") == "fireworks"
|
||||
assert "ollama" not in install_extras
|
||||
|
||||
async def test_load_model_data_surfaces_installed_unprofiled_recommended(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1872,7 +1986,7 @@ class TestModelSelectorInstallRouting:
|
||||
from deepagents_code import config_manifest
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
spec = "baseten:moonshotai/Kimi-K2.6"
|
||||
spec = "baseten:moonshotai/Kimi-K2.7-Code"
|
||||
assert spec in model_selector._RECOMMENDED_MODELS
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -1965,7 +2079,7 @@ class TestModelSelectorInstallRouting:
|
||||
async def test_load_model_data_skips_uninstalled_when_disabled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Onboarding (`include_uninstalled=False`) hides uninstalled providers."""
|
||||
"""Explicitly disabling uninstalled recommendations hides providers."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -2019,6 +2133,40 @@ enabled = false
|
||||
assert not any(spec.startswith("baseten:") for spec in specs)
|
||||
assert "baseten" not in install_extras
|
||||
|
||||
async def test_curated_uninstalled_provider_defers_to_launch_install(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Onboarding selections install from the launch flow before auth."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
results: list[tuple[str, str] | None] = []
|
||||
screen = ModelSelectorScreen(
|
||||
current_model="openai:gpt-5.5",
|
||||
current_provider="openai",
|
||||
curated=True,
|
||||
result_callback=results.append,
|
||||
)
|
||||
dismiss = MagicMock()
|
||||
screen.dismiss = dismiss # ty: ignore
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.config_manifest.provider_install_extra",
|
||||
lambda _provider: "baseten",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.config_manifest.is_provider_package_installed",
|
||||
lambda _provider: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"get_provider_auth_status",
|
||||
lambda _provider: pytest.fail("auth should wait until after install"),
|
||||
)
|
||||
|
||||
screen._select_with_auth_check("baseten:zai-org/GLM-5.2", "baseten")
|
||||
|
||||
assert results == [("baseten:zai-org/GLM-5.2", "baseten")]
|
||||
dismiss.assert_called_once_with(("baseten:zai-org/GLM-5.2", "baseten"))
|
||||
|
||||
async def test_select_uninstalled_provider_prompts_install(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -2046,7 +2194,9 @@ enabled = false
|
||||
lambda s, cb=None, *_a, **_k: pushed.append((s, cb)),
|
||||
)
|
||||
|
||||
screen._select_with_auth_check("baseten:moonshotai/Kimi-K2.6", "baseten")
|
||||
screen._select_with_auth_check(
|
||||
"baseten:moonshotai/Kimi-K2.7-Code", "baseten"
|
||||
)
|
||||
|
||||
assert len(pushed) == 1
|
||||
assert isinstance(pushed[0][0], InstallProviderConfirmScreen)
|
||||
@@ -2076,7 +2226,7 @@ enabled = false
|
||||
)
|
||||
|
||||
screen._prompt_install_provider(
|
||||
"baseten:moonshotai/Kimi-K2.6", "baseten", "baseten"
|
||||
"baseten:moonshotai/Kimi-K2.7-Code", "baseten", "baseten"
|
||||
)
|
||||
on_confirm = pushed[0][1]
|
||||
assert on_confirm is not None
|
||||
@@ -2085,7 +2235,7 @@ enabled = false
|
||||
|
||||
assert screen.pending_install_extra == "baseten"
|
||||
assert app.dismissed is True
|
||||
assert app.result == ("baseten:moonshotai/Kimi-K2.6", "baseten")
|
||||
assert app.result == ("baseten:moonshotai/Kimi-K2.7-Code", "baseten")
|
||||
|
||||
async def test_decline_install_stays_on_selector(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -2111,7 +2261,7 @@ enabled = false
|
||||
)
|
||||
|
||||
screen._prompt_install_provider(
|
||||
"baseten:moonshotai/Kimi-K2.6", "baseten", "baseten"
|
||||
"baseten:moonshotai/Kimi-K2.7-Code", "baseten", "baseten"
|
||||
)
|
||||
on_confirm = pushed[0][1]
|
||||
assert on_confirm is not None
|
||||
@@ -2154,7 +2304,7 @@ enabled = false
|
||||
the `install_required` flag, so uninstalled rows turned bright after
|
||||
the cursor passed over them and never reverted.
|
||||
"""
|
||||
install_spec = "baseten:moonshotai/Kimi-K2.6"
|
||||
install_spec = "baseten:moonshotai/Kimi-K2.7-Code"
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
@@ -2203,7 +2353,7 @@ enabled = false
|
||||
install-required model also surfaces at the top as a recent pick, so
|
||||
cursoring onto then off that Recent row must re-dim it just the same.
|
||||
"""
|
||||
install_spec = "baseten:moonshotai/Kimi-K2.6"
|
||||
install_spec = "baseten:moonshotai/Kimi-K2.7-Code"
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
|
||||
@@ -580,6 +580,20 @@ class TestThreadSelectorTabSort:
|
||||
await pilot.pause()
|
||||
assert filter_input.has_focus
|
||||
|
||||
def test_select_options_update_before_overlay_mount_is_safe(self) -> None:
|
||||
"""Agent option refresh can run before the overlay child is mounted."""
|
||||
select = ThreadScopeSelect(
|
||||
[("Loading...", "__loading__")],
|
||||
value="__loading__",
|
||||
allow_blank=False,
|
||||
id="thread-agent-select",
|
||||
classes="thread-agent-select",
|
||||
)
|
||||
|
||||
select.set_options([("All agents", "__all__"), ("agent", "agent")])
|
||||
|
||||
assert select.value == "__all__"
|
||||
|
||||
async def test_thread_load_preserves_open_agent_dropdown_focus(self) -> None:
|
||||
"""Async thread loading should not move focus away from an open dropdown."""
|
||||
started = asyncio.Event()
|
||||
|
||||
Reference in New Issue
Block a user