diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index cc095cb2..8a485ab9 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -522,28 +522,35 @@ def get_options(self) -> List[StepOption]: try: from app.tui.mcp_settings import list_mcp_servers servers = list_mcp_servers() - - # Create a lookup by name - server_lookup = {s["name"]: s for s in servers} - - # Return only recommended servers that exist in config - options = [] - for name, (icon, requires_setup) in self.RECOMMENDED_SERVERS.items(): - if name in server_lookup: - server = server_lookup[name] - label = server["name"].replace("-", " ").replace(" mcp", "").title() - options.append(StepOption( - value=server["name"], - label=label, - description=server.get("description", f"MCP server: {server['name']}"), - default=server.get("enabled", False), - icon=icon, - requires_setup=requires_setup - )) - return options - except ImportError: + except Exception: + # If MCP config is completely broken, show nothing rather than + # crashing the wizard — the user can configure later in Settings. return [] + # Create a lookup by name + server_lookup = {s["name"]: s for s in servers} + + # Return only recommended servers that exist in config + options = [] + for name, (icon, requires_setup) in self.RECOMMENDED_SERVERS.items(): + if name in server_lookup: + server = server_lookup[name] + label = server["name"].replace("-", " ").replace(" mcp", "").title() + # Append platform warning to description when server paths + # are incompatible with the current OS + desc = server.get("description", f"MCP server: {server['name']}") + if server.get("platform_blocked"): + label += " (⚠ Windows-only — requires setup on this OS)" + options.append(StepOption( + value=server["name"], + label=label, + description=desc, + default=server.get("enabled", False), + icon=icon, + requires_setup=requires_setup, + )) + return options + def validate(self, value: Any) -> tuple[bool, Optional[str]]: # Value should be a list of server names if not isinstance(value, list): diff --git a/app/tui/mcp_settings.py b/app/tui/mcp_settings.py index 6696e5ff..e6236943 100644 --- a/app/tui/mcp_settings.py +++ b/app/tui/mcp_settings.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import sys from pathlib import Path from typing import Dict, List, Optional, Any @@ -13,6 +14,23 @@ MCP_CONFIG_PATH = APP_CONFIG_PATH / "mcp_config.json" +def _is_windows_path(path: str) -> bool: + """Check if a path uses Windows drive-letter syntax (e.g. C:/...).""" + return bool(path) and len(path) >= 2 and path[0].isalpha() and path[1] == ":" + + +def _path_usable_on_current_platform(command: str, args: list) -> bool: + """Return False if command/args reference paths not valid on this OS.""" + if sys.platform == "win32": + return True + if _is_windows_path(command): + return False + for arg in args or []: + if _is_windows_path(arg): + return False + return True + + def load_mcp_config() -> MCPConfig: """Load MCP configuration from file.""" try: @@ -34,10 +52,27 @@ def save_mcp_config(config: MCPConfig) -> bool: def list_mcp_servers() -> List[Dict[str, Any]]: - """Get list of configured MCP servers with their status.""" - config = load_mcp_config() + """Get list of configured MCP servers with their status. + + Servers with platform-incompatible paths (e.g. Windows paths on macOS) + are annotated with a ``platform_blocked`` flag so the UI can explain why + they cannot be started. + """ + try: + config = load_mcp_config() + except Exception as exc: + logger.error(f"Failed to load MCP config: {exc}") + return [] servers = [] for server in config.mcp_servers: + platform_blocked = not _path_usable_on_current_platform( + server.command or "", getattr(server, "args", []) or [] + ) + if platform_blocked: + logger.debug( + "MCP server %s has platform-specific paths — skipping on %s", + server.name, sys.platform, + ) servers.append({ "name": server.name, "description": server.description, @@ -46,6 +81,7 @@ def list_mcp_servers() -> List[Dict[str, Any]]: "command": server.command, "action_set": server.resolved_action_set_name, "env": server.env, + "platform_blocked": platform_blocked, }) return servers diff --git a/app/tui/onboarding/widgets.py b/app/tui/onboarding/widgets.py index 44116a68..d2d5d9eb 100644 --- a/app/tui/onboarding/widgets.py +++ b/app/tui/onboarding/widgets.py @@ -286,6 +286,11 @@ class OnboardingWizardScreen(Screen): CSS = ONBOARDING_CSS + BINDINGS = [ + ("ctrl+s", "skip_step", "Skip"), + ("escape", "cancel", "Cancel"), + ] + def __init__(self, handler: "TUIHardOnboarding"): super().__init__() self._handler = handler @@ -695,7 +700,19 @@ def _complete(self) -> None: self._handler.on_complete(cancelled=False) self.app.pop_screen() + def action_skip_step(self) -> None: + """Skip the current optional step (Ctrl+S).""" + step = self._handler.get_step(self._current_step) + if not step.required: + self._skip_step() + def action_cancel(self) -> None: """Handle Escape key to cancel wizard.""" self._handler.on_complete(cancelled=True) self.app.pop_screen() + + def action_focus_nav(self) -> None: + """Focus the navigation bar (Tab).""" + nav = self.query_one("#nav-actions") + if hasattr(nav, 'focus'): + nav.focus() diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index e23ac229..f2f0c7f9 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -509,6 +509,21 @@ export function OnboardingPage() { }, [onboardingStep, selectedValue, textValue, orModel, proxiedVia, ollamaUrl, formValues, submitOnboardingStep]) const handleSkip = useCallback(() => skipOnboardingStep(), [skipOnboardingStep]) + + // Ctrl+S to skip optional steps (matches TUI behavior) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + if (onboardingStep && !onboardingStep.required) { + e.preventDefault() + skipOnboardingStep() + } + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [onboardingStep, skipOnboardingStep]) + const handleBack = useCallback(() => goBackOnboardingStep(), [goBackOnboardingStep]) const isMultiSelect = onboardingStep?.name === 'mcp' || onboardingStep?.name === 'skills'