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:
Mason Daugherty
2026-06-24 00:59:49 -04:00
committed by GitHub
parent 249ae5cdbf
commit 0827bf1b63
9 changed files with 1015 additions and 148 deletions
+124 -26
View File
@@ -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:
+171 -48
View File
@@ -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:
+177 -11
View File
@@ -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:
+230 -8
View File
@@ -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()
+163 -13
View File
@@ -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()