From 599ccdf754feecf84916d469f8e2a61be619766f Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 22 Jan 2026 16:03:55 +0100 Subject: [PATCH 1/4] [UX] (Minor) Allow to select the project interactively (if the terminal is in the interactive mode) --- src/dstack/_internal/cli/commands/project.py | 89 +++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/src/dstack/_internal/cli/commands/project.py b/src/dstack/_internal/cli/commands/project.py index 0f6e5b4db5..2f97b48cb9 100644 --- a/src/dstack/_internal/cli/commands/project.py +++ b/src/dstack/_internal/cli/commands/project.py @@ -1,8 +1,10 @@ import argparse +import sys from typing import Any, Union from requests import HTTPError from rich.table import Table +from simple_term_menu import TerminalMenu import dstack.api.server from dstack._internal.cli.commands import BaseCommand @@ -14,6 +16,59 @@ logger = get_logger(__name__) +def select_default_project(): + config_manager = ConfigManager() + + project_configs = config_manager.list_project_configs() + default_project = config_manager.get_project_config() + + if len(project_configs) == 0: + raise CLIError("No projects configured. Use [code]dstack project add[/] to add a project.") + + max_project_len = max(len(pc.name) for pc in project_configs) if project_configs else 0 + max_url_len = max(len(pc.url) for pc in project_configs) if project_configs else 0 + project_col_width = max(max_project_len, len("PROJECT")) + url_col_width = max(max_url_len, len("URL")) + default_col_width = len("DEFAULT") + + cursor_width = 2 + header = f"{'':<{cursor_width}}{'PROJECT':<{project_col_width}} {'URL':<{url_col_width}} {'DEFAULT':^{default_col_width}}" + + menu_entries = [] + default_index = None + for i, project_config in enumerate(project_configs): + is_default = project_config.name == default_project.name if default_project else False + project_name = project_config.name.ljust(project_col_width) + url = project_config.url.ljust(url_col_width) + default_marker = ( + "✓".center(default_col_width) if is_default else "".center(default_col_width) + ) + entry = f"{project_name} {url} {default_marker}" + if is_default: + default_index = i + menu_entries.append(entry) + + terminal_menu = TerminalMenu( + menu_entries=menu_entries, + title=f"Select the default project (↑↓ Enter):\n{header}", + cycle_cursor=True, + cursor_index=default_index if default_index is not None else 0, + show_search_hint=False, + ) + selected_index = terminal_menu.show() + + if selected_index is not None and isinstance(selected_index, int): + selected_project = project_configs[selected_index] + config_manager.configure_project( + name=selected_project.name, + url=selected_project.url, + token=selected_project.token, + default=True, + ) + config_manager.save() + console.print("[grey58]OK[/]") + + class ProjectCommand(BaseCommand): NAME = "project" DESCRIPTION = "Manage projects configs" @@ -67,14 +122,17 @@ def _register(self): # Set default subcommand set_default_parser = subparsers.add_parser("set-default", help="Set default project") set_default_parser.add_argument( - "name", type=str, help="The name of the project to set as default" + "name", + type=str, + nargs="?" if sys.stdin.isatty() else None, + help="The name of the project to set as default", ) set_default_parser.set_defaults(subfunc=self._set_default) def _command(self, args: argparse.Namespace): super()._command(args) if not hasattr(args, "subfunc"): - args.subfunc = self._list + args.subfunc = self._project args.subfunc(args) def _add(self, args: argparse.Namespace): @@ -156,14 +214,23 @@ def _list(self, args: argparse.Namespace): console.print(table) + def _project(self, args: argparse.Namespace): + if not sys.stdin.isatty() or getattr(args, "verbose", False): + self._list(args) + else: + select_default_project() + def _set_default(self, args: argparse.Namespace): - config_manager = ConfigManager() - project_config = config_manager.get_project_config(args.name) - if project_config is None: - raise CLIError(f"Project '{args.name}' not found") + if sys.stdin.isatty() and not getattr(args, "name", False): + select_default_project() + else: + config_manager = ConfigManager() + project_config = config_manager.get_project_config(args.name) + if project_config is None: + raise CLIError(f"Project '{args.name}' not found") - config_manager.configure_project( - name=args.name, url=project_config.url, token=project_config.token, default=True - ) - config_manager.save() - console.print("[grey58]OK[/]") + config_manager.configure_project( + name=args.name, url=project_config.url, token=project_config.token, default=True + ) + config_manager.save() + console.print("[grey58]OK[/]") From e60f9f3ae149002ee348850372344bb1dbb9102d Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 22 Jan 2026 17:04:02 +0100 Subject: [PATCH 2/4] [UX] (Minor) Allow to select the project interactively (if the terminal is in the interactive mode) Fixing test and review --- src/dstack/_internal/cli/commands/project.py | 29 +++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/dstack/_internal/cli/commands/project.py b/src/dstack/_internal/cli/commands/project.py index 2f97b48cb9..484938e59a 100644 --- a/src/dstack/_internal/cli/commands/project.py +++ b/src/dstack/_internal/cli/commands/project.py @@ -4,7 +4,13 @@ from requests import HTTPError from rich.table import Table -from simple_term_menu import TerminalMenu + +try: + from simple_term_menu import TerminalMenu # type: ignore[assignment] + + _is_menu_available = sys.stdin.isatty() +except (ImportError, NotImplementedError): + _is_menu_available = False import dstack.api.server from dstack._internal.cli.commands import BaseCommand @@ -16,7 +22,10 @@ logger = get_logger(__name__) -def select_default_project(): +def show_default_project_menu(): + if not _is_menu_available: + raise CLIError("Interactive menu is not supported on this platform") + config_manager = ConfigManager() project_configs = config_manager.list_project_configs() @@ -48,7 +57,7 @@ def select_default_project(): default_index = i menu_entries.append(entry) - terminal_menu = TerminalMenu( + terminal_menu = TerminalMenu( # pyright: ignore[reportPossiblyUnboundVariable] menu_entries=menu_entries, title=f"Select the default project (↑↓ Enter):\n{header}", cycle_cursor=True, @@ -124,7 +133,7 @@ def _register(self): set_default_parser.add_argument( "name", type=str, - nargs="?" if sys.stdin.isatty() else None, + nargs="?" if _is_menu_available else None, help="The name of the project to set as default", ) set_default_parser.set_defaults(subfunc=self._set_default) @@ -215,15 +224,13 @@ def _list(self, args: argparse.Namespace): console.print(table) def _project(self, args: argparse.Namespace): - if not sys.stdin.isatty() or getattr(args, "verbose", False): - self._list(args) + if _is_menu_available and not getattr(args, "verbose", False): + show_default_project_menu() else: - select_default_project() + self._list(args) def _set_default(self, args: argparse.Namespace): - if sys.stdin.isatty() and not getattr(args, "name", False): - select_default_project() - else: + if args.name: config_manager = ConfigManager() project_config = config_manager.get_project_config(args.name) if project_config is None: @@ -234,3 +241,5 @@ def _set_default(self, args: argparse.Namespace): ) config_manager.save() console.print("[grey58]OK[/]") + else: + show_default_project_menu() From 0e8a728de73620fea96a6b6161ac56542fc67661 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Fri, 23 Jan 2026 12:33:54 +0100 Subject: [PATCH 3/4] Replace simple-term-menu with questionary for project selection in CLI, updating dependencies in pyproject.toml accordingly. --- pyproject.toml | 2 +- src/dstack/_internal/cli/commands/project.py | 41 +++++++------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2fe97f2cbb..dd9b3b7021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "rich", "rich-argparse", "tqdm", - "simple-term-menu", + "questionary>=2.0.1", "pydantic>=1.10.10,<2.0.0", "pydantic-duality>=1.2.4", "websocket-client", diff --git a/src/dstack/_internal/cli/commands/project.py b/src/dstack/_internal/cli/commands/project.py index 484938e59a..486f6f4aca 100644 --- a/src/dstack/_internal/cli/commands/project.py +++ b/src/dstack/_internal/cli/commands/project.py @@ -6,10 +6,10 @@ from rich.table import Table try: - from simple_term_menu import TerminalMenu # type: ignore[assignment] + import questionary _is_menu_available = sys.stdin.isatty() -except (ImportError, NotImplementedError): +except (ImportError, NotImplementedError, AttributeError): _is_menu_available = False import dstack.api.server @@ -34,37 +34,24 @@ def show_default_project_menu(): if len(project_configs) == 0: raise CLIError("No projects configured. Use [code]dstack project add[/] to add a project.") - max_project_len = max(len(pc.name) for pc in project_configs) if project_configs else 0 - max_url_len = max(len(pc.url) for pc in project_configs) if project_configs else 0 - project_col_width = max(max_project_len, len("PROJECT")) - url_col_width = max(max_url_len, len("URL")) - default_col_width = len("DEFAULT") - - cursor_width = 2 - header = f"{'':<{cursor_width}}{'PROJECT':<{project_col_width}} {'URL':<{url_col_width}} {'DEFAULT':^{default_col_width}}" - menu_entries = [] default_index = None for i, project_config in enumerate(project_configs): is_default = project_config.name == default_project.name if default_project else False - project_name = project_config.name.ljust(project_col_width) - url = project_config.url.ljust(url_col_width) - default_marker = ( - "✓".center(default_col_width) if is_default else "".center(default_col_width) - ) - entry = f"{project_name} {url} {default_marker}" + entry = f"{project_config.name} ({project_config.url})" if is_default: default_index = i - menu_entries.append(entry) - - terminal_menu = TerminalMenu( # pyright: ignore[reportPossiblyUnboundVariable] - menu_entries=menu_entries, - title=f"Select the default project (↑↓ Enter):\n{header}", - cycle_cursor=True, - cursor_index=default_index if default_index is not None else 0, - show_search_hint=False, - ) - selected_index = terminal_menu.show() + menu_entries.append((entry, i)) + + choices = [questionary.Choice(title=entry, value=index) for entry, index in menu_entries] # pyright: ignore[reportPossiblyUnboundVariable] + default_value = default_index + selected_index = questionary.select( # pyright: ignore[reportPossiblyUnboundVariable] + message="Select the default project (↑↓ Enter):", + choices=choices, + default=default_value, # pyright: ignore[reportArgumentType] + qmark="", + instruction="", + ).ask() if selected_index is not None and isinstance(selected_index, int): selected_project = project_configs[selected_index] From 95964e6597e9528131824410b53e857402550313 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Fri, 23 Jan 2026 13:29:40 +0100 Subject: [PATCH 4/4] Refactored the project selection logic to ensure it can be used also from other commands (e.g. `dstack login`. --- src/dstack/_internal/cli/commands/project.py | 81 ++++++++++++++------ 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/src/dstack/_internal/cli/commands/project.py b/src/dstack/_internal/cli/commands/project.py index 486f6f4aca..db4a7a5eb9 100644 --- a/src/dstack/_internal/cli/commands/project.py +++ b/src/dstack/_internal/cli/commands/project.py @@ -1,6 +1,6 @@ import argparse import sys -from typing import Any, Union +from typing import Any, Optional, Union from requests import HTTPError from rich.table import Table @@ -8,31 +8,45 @@ try: import questionary - _is_menu_available = sys.stdin.isatty() + is_project_menu_supported = sys.stdin.isatty() except (ImportError, NotImplementedError, AttributeError): - _is_menu_available = False + is_project_menu_supported = False import dstack.api.server from dstack._internal.cli.commands import BaseCommand from dstack._internal.cli.utils.common import add_row_from_dict, confirm_ask, console from dstack._internal.core.errors import ClientError, CLIError +from dstack._internal.core.models.config import ProjectConfig from dstack._internal.core.services.configs import ConfigManager from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) -def show_default_project_menu(): - if not _is_menu_available: - raise CLIError("Interactive menu is not supported on this platform") +def select_default_project( + project_configs: list[ProjectConfig], default_project: Optional[ProjectConfig] +) -> Optional[ProjectConfig]: + """Show an interactive menu to select a default project. + + This method only prompts for selection and does not update the configuration. + Use `ConfigManager.configure_project()` and `ConfigManager.save()` to persist + the selected project as default. - config_manager = ConfigManager() + Args: + project_configs: Non-empty list of available project configurations. + default_project: Currently default project, if any. - project_configs = config_manager.list_project_configs() - default_project = config_manager.get_project_config() + Returns: + Selected project configuration, or None if cancelled. + + Raises: + CLIError: If `is_project_menu_supported` is False or `project_configs` is empty. + """ + if not is_project_menu_supported: + raise CLIError("Interactive menu is not supported on this platform") if len(project_configs) == 0: - raise CLIError("No projects configured. Use [code]dstack project add[/] to add a project.") + raise CLIError("No projects configured") menu_entries = [] default_index = None @@ -46,23 +60,16 @@ def show_default_project_menu(): choices = [questionary.Choice(title=entry, value=index) for entry, index in menu_entries] # pyright: ignore[reportPossiblyUnboundVariable] default_value = default_index selected_index = questionary.select( # pyright: ignore[reportPossiblyUnboundVariable] - message="Select the default project (↑↓ Enter):", + message="Select the default project:", choices=choices, default=default_value, # pyright: ignore[reportArgumentType] qmark="", - instruction="", + instruction="(↑↓ Enter)", ).ask() if selected_index is not None and isinstance(selected_index, int): - selected_project = project_configs[selected_index] - config_manager.configure_project( - name=selected_project.name, - url=selected_project.url, - token=selected_project.token, - default=True, - ) - config_manager.save() - console.print("[grey58]OK[/]") + return project_configs[selected_index] + return None class ProjectCommand(BaseCommand): @@ -120,7 +127,7 @@ def _register(self): set_default_parser.add_argument( "name", type=str, - nargs="?" if _is_menu_available else None, + nargs="?" if is_project_menu_supported else None, help="The name of the project to set as default", ) set_default_parser.set_defaults(subfunc=self._set_default) @@ -211,8 +218,20 @@ def _list(self, args: argparse.Namespace): console.print(table) def _project(self, args: argparse.Namespace): - if _is_menu_available and not getattr(args, "verbose", False): - show_default_project_menu() + if is_project_menu_supported and not getattr(args, "verbose", False): + config_manager = ConfigManager() + project_configs = config_manager.list_project_configs() + default_project = config_manager.get_project_config() + selected_project = select_default_project(project_configs, default_project) + if selected_project is not None: + config_manager.configure_project( + name=selected_project.name, + url=selected_project.url, + token=selected_project.token, + default=True, + ) + config_manager.save() + console.print("[grey58]OK[/]") else: self._list(args) @@ -229,4 +248,16 @@ def _set_default(self, args: argparse.Namespace): config_manager.save() console.print("[grey58]OK[/]") else: - show_default_project_menu() + config_manager = ConfigManager() + project_configs = config_manager.list_project_configs() + default_project = config_manager.get_project_config() + selected_project = select_default_project(project_configs, default_project) + if selected_project is not None: + config_manager.configure_project( + name=selected_project.name, + url=selected_project.url, + token=selected_project.token, + default=True, + ) + config_manager.save() + console.print("[grey58]OK[/]")