diff --git a/README.md b/README.md index 5f8dcabb14..9926aa6cce 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,10 @@ Profiles use the global Windows-hosted agent by default. In a profile's installed in that profile's WSL distro. The picker only lists the Windows host and that one distro. An explicit profile selection is strict: if that agent cannot start, the pane reports the failure without switching to another agent. +**Command palette agent** independently selects the agent used by `?` +and the interactive delegate action for that profile. Its host or WSL selection +is also strict: if the selected agent is unavailable, delegation reports that +error instead of falling back to another agent or execution environment. ### Agent Management diff --git a/doc/cascadia/profiles.schema.json b/doc/cascadia/profiles.schema.json index ab1e609f74..3c5ea2871d 100644 --- a/doc/cascadia/profiles.schema.json +++ b/doc/cascadia/profiles.schema.json @@ -3203,6 +3203,14 @@ ], "pattern": "^(|host:[^:]+|wsl:[^:]+:[^:]+)$" }, + "commandPaletteAgent": { + "description": "Selects the agent used for command palette delegation from this profile. An empty value uses the global Windows-hosted delegate agent. An explicit host or WSL selection is strict and never falls back to another agent.", + "type": [ + "string", + "null" + ], + "pattern": "^(|host:[^:]+|wsl:[^:]+:[^:]+)$" + }, "suppressApplicationTitle": { "description": "When set to true, tabTitle overrides the default title of the tab and any title change messages from the application will be suppressed. When set to false, tabTitle behaves as normal.", "type": "boolean", diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index cd4a194b25..d77779a604 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1318,28 +1318,81 @@ namespace winrt::TerminalApp::implementation return; } - // Resolve agent CLI from structured settings (acpAgent/acpModel). + // Resolve the command-palette agent from the active pane's profile. + // An empty profile value follows the global delegate setting on the + // Windows host. An explicit profile value selects both the agent and + // its exact execution source; WTA must not infer or fall back from it. const auto& globals = _settings.GlobalSettings(); - const auto agentCliPath = _ResolveEffectiveAgentCliPath(globals, [this]() { return _DetectAgentCli(); }); + auto delegateAgent = _ResolveEffectiveDelegateAgent(globals); + auto delegateModel = globals.DelegateModel(); + winrt::hstring delegateSource{ L"host" }; + winrt::hstring delegateWslDistro; + if (const auto sourceProfile = _GetAgentSourceProfile(_GetFocusedTabImpl())) + { + const auto configuredValue = sourceProfile.CommandPaletteAgent(); + const std::wstring_view configured{ configuredValue }; + if (!configured.empty()) + { + const auto backend = ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Parse(configured); + if (!backend) + { + _agentPaneLog("ABORT: invalid profile commandPaletteAgent"); + return; + } + namespace Registry = ::Microsoft::Terminal::Settings::Model::AgentRegistry; + const auto allowedAgents = Registry::FilteredDelegateAgents(); + const auto knownAndAllowed = std::any_of( + allowedAgents.begin(), + allowedAgents.end(), + [&](const auto& agent) { + return agent.id == backend->agentId; + }); + // Only an empty profile setting follows the global delegate. + // An explicit unknown or blocked selection must fail closed. + if (!knownAndAllowed) + { + delegateAgent = {}; + } + else + { + delegateAgent = winrt::hstring{ backend->agentId }; + // There is no profile-scoped model setting — the profile's + // commandPaletteAgent selects only the agent and its exact + // execution source, so the global DelegateModel still + // applies and must not be cleared here. + if (backend->source == ::Microsoft::Terminal::Settings::Model::AgentPaneBackendSource::Wsl) + { + delegateSource = L"wsl"; + delegateWslDistro = winrt::hstring{ backend->wslDistro }; + } + } + } + } - // If no agent resolved and an AllowedAgents policy is active, bail out. - // This covers both "policy blocks ALL agents" and "policy allows some - // agents but none are installed" — in either case we must not launch - // WTA without --agent, because WTA's own fallback detection would - // bypass GPO and pick an unauthorized agent (e.g. copilot). - if (agentCliPath.empty() && AgentPolicy::IsAllowedAgentsPolicyConfigured()) + // `wta delegate` is invoked with an exact delegate command below, so + // absence is an error. Do not let WTA derive a replacement from the + // ACP agent command. + if (delegateAgent.empty()) { - _agentPaneLog("ABORT: delegation blocked by GPO — no agents allowed"); - if (auto tip{ FindName(L"WindowIdToast").try_as() }) + _agentPaneLog("ABORT: no allowed delegate agent configured"); + if (AgentPolicy::IsAllowedAgentsPolicyConfigured()) { - _UpdateTeachingTipTheme(tip.try_as()); - tip.Title(RS_(L"AgentBlockedByPolicyTitle")); - tip.Subtitle(RS_(L"AgentBlockedByPolicySubtitle")); - tip.IsOpen(true); + if (auto tip{ FindName(L"WindowIdToast").try_as() }) + { + _UpdateTeachingTipTheme(tip.try_as()); + tip.Title(RS_(L"AgentBlockedByPolicyTitle")); + tip.Subtitle(RS_(L"AgentBlockedByPolicySubtitle")); + tip.IsOpen(true); + } } return; } + // The ACP command is retained for backward-compatible WTA context, but + // it is never used as a delegate fallback because --delegate-agent and + // --delegate-source are always supplied. + const auto agentCliPath = _ResolveEffectiveAgentCliPath(globals, [this]() { return _DetectAgentCli(); }); + // Helper: escape and quote an argument for the command line. auto quoteArg = [](std::wstring_view arg) -> std::wstring { std::wstring escaped{ arg }; @@ -1350,7 +1403,9 @@ namespace winrt::TerminalApp::implementation return L"\"" + escaped + L"\""; }; - // Build: wta [--language ] delegate --agent --delegate-agent "" + // Build: wta [--language ] delegate --agent + // --delegate-agent --delegate-source + // [--delegate-wsl-distro ] "" // // `--language` is a top-level Cli flag, so it must appear *before* the // `delegate` subcommand — otherwise clap rejects it and the process @@ -1370,12 +1425,12 @@ namespace winrt::TerminalApp::implementation cmdline += L" --agent " + quoteArg(std::wstring_view{ agentCliPath }); } - const auto delegateAgent = _ResolveEffectiveDelegateAgent(globals); - if (!delegateAgent.empty()) + cmdline += L" --delegate-agent " + quoteArg(std::wstring_view{ delegateAgent }); + cmdline += L" --delegate-source " + quoteArg(std::wstring_view{ delegateSource }); + if (!delegateWslDistro.empty()) { - cmdline += L" --delegate-agent " + quoteArg(std::wstring_view{ delegateAgent }); + cmdline += L" --delegate-wsl-distro " + quoteArg(std::wstring_view{ delegateWslDistro }); } - const auto delegateModel = globals.DelegateModel(); if (!delegateModel.empty()) { cmdline += L" --delegate-model " + quoteArg(std::wstring_view{ delegateModel }); @@ -1972,7 +2027,8 @@ namespace winrt::TerminalApp::implementation } else if (sourceProfile) { - const auto configured = std::wstring_view{ sourceProfile.AgentPaneBackend() }; + const auto configuredValue = sourceProfile.AgentPaneBackend(); + const std::wstring_view configured{ configuredValue }; hasProfileBackend = !configured.empty(); if (const auto backend = ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Parse(configured)) { diff --git a/src/cascadia/TerminalSettingsEditor/ProfileViewModel.cpp b/src/cascadia/TerminalSettingsEditor/ProfileViewModel.cpp index 2aa6ee268f..c56ed75348 100644 --- a/src/cascadia/TerminalSettingsEditor/ProfileViewModel.cpp +++ b/src/cascadia/TerminalSettingsEditor/ProfileViewModel.cpp @@ -45,6 +45,13 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation return winrt::hstring{ agent.displayName }; } } + for (const auto& agent : Registry::BuiltinDelegateAgents) + { + if (agent.id == id) + { + return winrt::hstring{ agent.displayName }; + } + } return id.empty() ? winrt::hstring{ L"Agent" } : winrt::hstring{ id }; } @@ -60,6 +67,102 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation return SearchPathW(nullptr, commandShim.c_str(), nullptr, MAX_PATH, path, nullptr) > 0; } + static void _RebuildAgentBackendList( + const Windows::Foundation::Collections::IObservableVector& list, + const std::wstring_view globalId, + const std::wstring_view selected, + const std::wstring_view wslDistro, + const std::vector& availableWslAgents, + const std::vector<::Microsoft::Terminal::Settings::Model::AgentRegistry::BuiltinAgent>& allowedAgents) + { + namespace Backend = ::Microsoft::Terminal::Settings::Model; + + std::vector entries; + const auto globalEntry = winrt::make( + L"", + winrt::hstring{ RS_fmt( + L"Profile_AgentPaneBackend_AgentFormat", + std::wstring{ std::wstring_view{ _AgentDisplayName(globalId) } }, + std::wstring{ L"Windows" }) }, + true); + + bool globalEntryAdded = false; + for (const auto& agent : allowedAgents) + { + if (_HostAgentInstalled(agent.id)) + { + const bool isGlobalAgent = agent.id == globalId; + entries.emplace_back(winrt::make( + isGlobalAgent ? + winrt::hstring{} : + winrt::hstring{ Backend::AgentPaneBackend::Host(agent.id) }, + winrt::hstring{ RS_fmt( + L"Profile_AgentPaneBackend_AgentFormat", + std::wstring{ agent.displayName }, + std::wstring{ L"Windows" }) }, + true)); + globalEntryAdded = globalEntryAdded || isGlobalAgent; + } + } + if (!globalEntryAdded) + { + entries.insert(entries.begin(), globalEntry); + } + + if (!wslDistro.empty()) + { + for (const auto& agent : allowedAgents) + { + if (std::find( + availableWslAgents.begin(), + availableWslAgents.end(), + std::wstring{ agent.id }) != availableWslAgents.end()) + { + entries.emplace_back(winrt::make( + winrt::hstring{ Backend::AgentPaneBackend::Wsl(wslDistro, agent.id) }, + winrt::hstring{ RS_fmt( + L"Profile_AgentPaneBackend_AgentFormat", + std::wstring{ agent.displayName }, + std::wstring{ wslDistro }) }, + true)); + } + } + } + + const auto selectedIsGlobalHost = + !globalId.empty() && + selected == Backend::AgentPaneBackend::Host(globalId); + const auto selectedPresent = + selectedIsGlobalHost || + std::any_of(entries.begin(), entries.end(), [&](const auto& entry) { + return std::wstring_view{ entry.Id() } == selected; + }); + if (!selected.empty() && !selectedPresent) + { + std::wstring label{ selected }; + if (const auto parsed = Backend::AgentPaneBackend::Parse(selected)) + { + const auto location = parsed->source == Backend::AgentPaneBackendSource::Wsl ? + parsed->wslDistro : + std::wstring{ L"Windows" }; + label = RS_fmt(L"Profile_AgentPaneBackend_AgentFormat", + std::wstring{ std::wstring_view{ _AgentDisplayName(parsed->agentId) } }, + location); + } + label += RS_(L"Profile_AgentPaneBackend_UnavailableSuffix"); + entries.emplace_back(winrt::make( + winrt::hstring{ selected }, + winrt::hstring{ label }, + true)); + } + + list.Clear(); + for (const auto& entry : entries) + { + list.Append(entry); + } + } + ProfileViewModel::ProfileViewModel(const Model::Profile& profile, const Model::CascadiaSettings& appSettings, const Windows::UI::Core::CoreDispatcher& dispatcher) : _profile{ profile }, _defaultAppearanceViewModel{ winrt::make(profile.DefaultAppearance().try_as()) }, @@ -76,6 +179,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation _InitializeCurrentBellSounds(); _agentPaneBackendList = winrt::single_threaded_observable_vector(); + _commandPaletteAgentList = winrt::single_threaded_observable_vector(); _RefreshAgentPaneBackendList(); // Add a property changed handler to our own property changed event. @@ -98,6 +202,10 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation { _NotifyChanges(L"CurrentAgentPaneBackend"); } + else if (viewModelProperty == L"CommandPaletteAgent") + { + _NotifyChanges(L"CurrentCommandPaletteAgent"); + } else if (viewModelProperty == L"Commandline") { _RefreshAgentPaneBackendList(); @@ -230,102 +338,60 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation } } - void ProfileViewModel::_RebuildAgentPaneBackendList( - const std::wstring_view wslDistro, - const std::vector& availableWslAgents) + Editor::AgentEntry ProfileViewModel::CurrentCommandPaletteAgent() { - namespace Registry = ::Microsoft::Terminal::Settings::Model::AgentRegistry; - namespace Backend = ::Microsoft::Terminal::Settings::Model; - - std::vector entries; - const auto globalId = _appSettings.GlobalSettings().EffectiveAcpAgent(); - const auto globalName = _AgentDisplayName(std::wstring_view{ globalId }); - const auto globalEntry = winrt::make( - L"", - winrt::hstring{ RS_fmt( - L"Profile_AgentPaneBackend_AgentFormat", - std::wstring{ std::wstring_view{ globalName } }, - std::wstring{ L"Windows" }) }, - true); - - const auto allowedAgents = Registry::FilteredAcpAgents(); - bool globalEntryAdded = false; - for (const auto& agent : allowedAgents) - { - if (_HostAgentInstalled(agent.id)) - { - const bool isGlobalAgent = agent.id == std::wstring_view{ globalId }; - entries.emplace_back(winrt::make( - isGlobalAgent ? - winrt::hstring{} : - winrt::hstring{ Backend::AgentPaneBackend::Host(agent.id) }, - winrt::hstring{ RS_fmt( - L"Profile_AgentPaneBackend_AgentFormat", - std::wstring{ agent.displayName }, - std::wstring{ L"Windows" }) }, - true)); - globalEntryAdded = globalEntryAdded || isGlobalAgent; - } - } - if (!globalEntryAdded) + auto selected = CommandPaletteAgent(); + const auto globalId = _appSettings.GlobalSettings().EffectiveDelegateAgent(); + if (!globalId.empty() && + selected == winrt::hstring{ ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Host(std::wstring_view{ globalId }) }) { - entries.insert(entries.begin(), globalEntry); + selected = {}; } - - if (!wslDistro.empty()) + for (uint32_t i = 0; i < _commandPaletteAgentList.Size(); ++i) { - for (const auto& agent : allowedAgents) + const auto entry = _commandPaletteAgentList.GetAt(i); + if (entry.Id() == selected) { - if (std::find( - availableWslAgents.begin(), - availableWslAgents.end(), - std::wstring{ agent.id }) != availableWslAgents.end()) - { - entries.emplace_back(winrt::make( - winrt::hstring{ Backend::AgentPaneBackend::Wsl(wslDistro, agent.id) }, - winrt::hstring{ RS_fmt( - L"Profile_AgentPaneBackend_AgentFormat", - std::wstring{ agent.displayName }, - std::wstring{ wslDistro }) }, - true)); - } + return entry; } } + return _commandPaletteAgentList.Size() > 0 ? _commandPaletteAgentList.GetAt(0) : nullptr; + } - const auto selected = std::wstring{ AgentPaneBackend() }; - const auto selectedIsGlobalHost = - !globalId.empty() && - selected == Backend::AgentPaneBackend::Host(std::wstring_view{ globalId }); - const auto selectedPresent = - selectedIsGlobalHost || - std::any_of(entries.begin(), entries.end(), [&](const auto& entry) { - return std::wstring_view{ entry.Id() } == selected; - }); - if (!selected.empty() && !selectedPresent) + void ProfileViewModel::CurrentCommandPaletteAgent(const Editor::AgentEntry& value) + { + if (value && CommandPaletteAgent() != value.Id()) { - std::wstring label{ selected }; - if (const auto parsed = Backend::AgentPaneBackend::Parse(selected)) - { - const auto location = parsed->source == Backend::AgentPaneBackendSource::Wsl ? - parsed->wslDistro : - std::wstring{ L"Windows" }; - label = RS_fmt(L"Profile_AgentPaneBackend_AgentFormat", - std::wstring{ std::wstring_view{ _AgentDisplayName(parsed->agentId) } }, - location); - } - label += RS_(L"Profile_AgentPaneBackend_UnavailableSuffix"); - entries.emplace_back(winrt::make( - winrt::hstring{ selected }, - winrt::hstring{ label }, - true)); + CommandPaletteAgent(value.Id()); + _NotifyChanges(L"CurrentCommandPaletteAgent"); } + } - _agentPaneBackendList.Clear(); - for (const auto& entry : entries) - { - _agentPaneBackendList.Append(entry); - } - _NotifyChanges(L"AgentPaneBackendList", L"CurrentAgentPaneBackend"); + void ProfileViewModel::_RebuildAgentBackendLists( + const std::wstring_view wslDistro, + const std::vector& availableWslAgents) + { + namespace Registry = ::Microsoft::Terminal::Settings::Model::AgentRegistry; + const auto& globals = _appSettings.GlobalSettings(); + _RebuildAgentBackendList( + _agentPaneBackendList, + std::wstring_view{ globals.EffectiveAcpAgent() }, + std::wstring_view{ AgentPaneBackend() }, + wslDistro, + availableWslAgents, + Registry::FilteredAcpAgents()); + _RebuildAgentBackendList( + _commandPaletteAgentList, + std::wstring_view{ globals.EffectiveDelegateAgent() }, + std::wstring_view{ CommandPaletteAgent() }, + wslDistro, + availableWslAgents, + Registry::FilteredDelegateAgents()); + _NotifyChanges( + L"AgentPaneBackendList", + L"CurrentAgentPaneBackend", + L"CommandPaletteAgentList", + L"CurrentCommandPaletteAgent"); } void ProfileViewModel::_RefreshAgentPaneBackendList() @@ -334,12 +400,12 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation const auto commandline = std::wstring{ Commandline() }; if (::Microsoft::Terminal::ShellIntegration::IsWslProfile(commandline)) { - _RebuildAgentPaneBackendList(L"WSL", {}); + _RebuildAgentBackendLists(L"WSL", {}); _ProbeWslAgentPaneBackendsAsync(commandline, generation); } else { - _RebuildAgentPaneBackendList({}, {}); + _RebuildAgentBackendLists({}, {}); } } @@ -396,7 +462,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation co_await wil::resume_foreground(dispatcher); if (generation == _agentPaneBackendProbeGeneration) { - _RebuildAgentPaneBackendList(distro, availableAgents); + _RebuildAgentBackendLists(distro, availableAgents); } } diff --git a/src/cascadia/TerminalSettingsEditor/ProfileViewModel.h b/src/cascadia/TerminalSettingsEditor/ProfileViewModel.h index 30d68be28c..fb854fb7ce 100644 --- a/src/cascadia/TerminalSettingsEditor/ProfileViewModel.h +++ b/src/cascadia/TerminalSettingsEditor/ProfileViewModel.h @@ -88,6 +88,9 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation Windows::Foundation::Collections::IObservableVector AgentPaneBackendList() const noexcept { return _agentPaneBackendList; } Editor::AgentEntry CurrentAgentPaneBackend(); void CurrentAgentPaneBackend(const Editor::AgentEntry& value); + Windows::Foundation::Collections::IObservableVector CommandPaletteAgentList() const noexcept { return _commandPaletteAgentList; } + Editor::AgentEntry CurrentCommandPaletteAgent(); + void CurrentCommandPaletteAgent(const Editor::AgentEntry& value); // general profile knowledge winrt::guid OriginalProfileGuid() const noexcept; @@ -130,6 +133,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation OBSERVABLE_PROJECTED_SETTING(_profile, Commandline); OBSERVABLE_PROJECTED_SETTING(_profile, StartingDirectory); OBSERVABLE_PROJECTED_SETTING(_profile, AgentPaneBackend); + OBSERVABLE_PROJECTED_SETTING(_profile, CommandPaletteAgent); OBSERVABLE_PROJECTED_SETTING(_profile, AntialiasingMode); OBSERVABLE_PROJECTED_SETTING(_profile.DefaultAppearance(), Opacity); OBSERVABLE_PROJECTED_SETTING(_profile.DefaultAppearance(), UseAcrylic); @@ -169,6 +173,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation Editor::AppearanceViewModel _defaultAppearanceViewModel; Windows::UI::Core::CoreDispatcher _dispatcher; Windows::Foundation::Collections::IObservableVector _agentPaneBackendList; + Windows::Foundation::Collections::IObservableVector _commandPaletteAgentList; uint64_t _agentPaneBackendProbeGeneration{ 0 }; winrt::Windows::UI::Xaml::Thickness _parsedPadding; @@ -176,8 +181,8 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation void _InitializeCurrentBellSounds(); void _PrepareModelForBellSoundModification(); void _MarkDuplicateBellSoundDirectories(); - void _RebuildAgentPaneBackendList(std::wstring_view wslDistro, - const std::vector& availableWslAgents); + void _RebuildAgentBackendLists(std::wstring_view wslDistro, + const std::vector& availableWslAgents); void _RefreshAgentPaneBackendList(); winrt::fire_and_forget _ProbeWslAgentPaneBackendsAsync(std::wstring commandline, uint64_t generation); diff --git a/src/cascadia/TerminalSettingsEditor/ProfileViewModel.idl b/src/cascadia/TerminalSettingsEditor/ProfileViewModel.idl index 3439ba6d96..6f91d2b859 100644 --- a/src/cascadia/TerminalSettingsEditor/ProfileViewModel.idl +++ b/src/cascadia/TerminalSettingsEditor/ProfileViewModel.idl @@ -79,6 +79,8 @@ namespace Microsoft.Terminal.Settings.Editor Windows.Foundation.Collections.IObservableVector AgentPaneBackendList { get; }; AgentEntry CurrentAgentPaneBackend; + Windows.Foundation.Collections.IObservableVector CommandPaletteAgentList { get; }; + AgentEntry CurrentCommandPaletteAgent; Boolean CanDeleteProfile { get; }; Boolean FocusDeleteButton; @@ -127,6 +129,7 @@ namespace Microsoft.Terminal.Settings.Editor OBSERVABLE_PROJECTED_PROFILE_SETTING(String, Commandline); OBSERVABLE_PROJECTED_PROFILE_SETTING(String, StartingDirectory); OBSERVABLE_PROJECTED_PROFILE_SETTING(String, AgentPaneBackend); + OBSERVABLE_PROJECTED_PROFILE_SETTING(String, CommandPaletteAgent); OBSERVABLE_PROJECTED_PROFILE_SETTING(Microsoft.Terminal.Control.TextAntialiasingMode, AntialiasingMode); OBSERVABLE_PROJECTED_PROFILE_SETTING(Int32, HistorySize); OBSERVABLE_PROJECTED_PROFILE_SETTING(Boolean, SnapOnInput); diff --git a/src/cascadia/TerminalSettingsEditor/Profiles_Base.xaml b/src/cascadia/TerminalSettingsEditor/Profiles_Base.xaml index 2da3484c67..019a7e1523 100644 --- a/src/cascadia/TerminalSettingsEditor/Profiles_Base.xaml +++ b/src/cascadia/TerminalSettingsEditor/Profiles_Base.xaml @@ -117,6 +117,20 @@ Style="{StaticResource ComboBoxSettingStyle}" /> + + + + + Wählen Sie den ACP-kompatiblen Agenten aus, der im Agentbereich verwendet wird. Für dieses Profil hat diese Auswahl Vorrang vor der globalen Auswahl für den Agentbereich. {Locked="ACP"} Description for the per-profile AI agent setting. + + Agent für die Befehlspalette + Header for choosing which AI agent handles command palette delegation for this profile. + + + Wählen Sie den Agent aus, der in diesem Profil für Delegierungen über die Befehlspalette verwendet wird. Diese Auswahl hat Vorrang vor der globalen Einstellung für den Delegat-Agenten. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw index fd9fc95980..5f91e67a8d 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw @@ -1367,6 +1367,14 @@ In this context, "agent" refers to an AI agent (e.g. Copilot, Claude, Gemini), n Choose the agent used in the agent pane that supports ACP. For this profile, this selection takes precedence over the global Agent pane selection. {Locked="ACP"} Description for the per-profile AI agent setting. + + Command palette agent + Header for choosing which AI agent handles command palette delegation for this profile. + + + Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw index 1bfb8a13a4..e7d497e9c4 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/es-ES/Resources.resw @@ -1245,6 +1245,14 @@ Elija el agente compatible con ACP que se usará en el panel del agente. Para este perfil, esta selección tiene prioridad sobre la selección global del panel del agente. {Locked="ACP"} Description for the per-profile AI agent setting. + + Agente de la paleta de comandos + Header for choosing which AI agent handles command palette delegation for this profile. + + + Elige el agente que se usa para delegar desde la paleta de comandos en este perfil. Esta selección tiene prioridad sobre la configuración global del agente delegado. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw index 90326607b2..f8c807c4de 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/fr-FR/Resources.resw @@ -1245,6 +1245,14 @@ Choisissez l’agent compatible avec ACP à utiliser dans le volet de l’agent. Pour ce profil, ce choix est prioritaire sur la sélection globale du volet de l’agent. {Locked="ACP"} Description for the per-profile AI agent setting. + + Agent de la palette de commandes + Header for choosing which AI agent handles command palette delegation for this profile. + + + Choisissez l’agent utilisé pour la délégation depuis la palette de commandes dans ce profil. Cette sélection est prioritaire sur le paramètre global de l’agent délégué. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw index 63bedb87e0..a2f69389c5 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/it-IT/Resources.resw @@ -1245,6 +1245,14 @@ Scegli l’agente compatibile con ACP da usare nel riquadro agente. Per questo profilo, questa selezione ha la precedenza sulla selezione globale del riquadro agente. {Locked="ACP"} Description for the per-profile AI agent setting. + + Agente del riquadro comandi + Header for choosing which AI agent handles command palette delegation for this profile. + + + Scegli l’agente usato per la delega dal riquadro comandi in questo profilo. Questa selezione ha la precedenza sull’impostazione globale dell’agente delegato. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw index 080d5db413..e8e323c373 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/ja-JP/Resources.resw @@ -1245,6 +1245,14 @@ エージェント ペインで使用する ACP 対応エージェントを選択します。このプロファイルでは、この選択がグローバルなエージェント ペインの選択より優先されます。 {Locked="ACP"} Description for the per-profile AI agent setting. + + コマンド パレットのエージェント + Header for choosing which AI agent handles command palette delegation for this profile. + + + このプロファイルでコマンド パレットからの委任に使用するエージェントを選択します。この選択は、グローバルな委任エージェント設定より優先されます。 + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw index ea97d6353f..40df611470 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/ko-KR/Resources.resw @@ -1245,6 +1245,14 @@ 에이전트 창에서 사용할 ACP 지원 에이전트를 선택하세요. 이 프로필에서는 이 선택 항목이 전역 에이전트 창 선택 항목보다 우선합니다. {Locked="ACP"} Description for the per-profile AI agent setting. + + 명령 팔레트 에이전트 + Header for choosing which AI agent handles command palette delegation for this profile. + + + 이 프로필에서 명령 팔레트 위임에 사용할 에이전트를 선택합니다. 이 선택은 전역 위임 에이전트 설정보다 우선합니다. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw index 7ca13f1a2f..eb24f7147d 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/pt-BR/Resources.resw @@ -1245,6 +1245,14 @@ Escolha o agente compatível com ACP que será usado no painel do agente. Para este perfil, esta seleção tem prioridade sobre a seleção global do painel do agente. {Locked="ACP"} Description for the per-profile AI agent setting. + + Agente da paleta de comandos + Header for choosing which AI agent handles command palette delegation for this profile. + + + Escolha o agente usado para delegação pela paleta de comandos neste perfil. Esta seleção tem precedência sobre a configuração global do agente delegado. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw index 19a296dece..ec0721b865 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw @@ -1247,6 +1247,14 @@ Čнοθśê ťнĕ ăĝĕήţ ùşéδ îή ťћε āġêʼnτ рâńё ťђăţ śùρρόřŧś ACP. ₣õř ťĥιѕ ряőƒїℓé, ţђĩş ѕéℓē¢ŧιбⁿ тåкёś φѓэĉзďелċ℮ öνёґ ťĥз ġĺôьåĺ Äĝёņţ ράπє ѕêłēçτíθŋ. !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! ! {Locked="ACP"} Description for the per-profile AI agent setting. + + Command palette agent + Header for choosing which AI agent handles command palette delegation for this profile. + + + Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. + Description for the per-profile command palette agent setting. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw index 19a296dece..ec0721b865 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw @@ -1247,6 +1247,14 @@ Čнοθśê ťнĕ ăĝĕήţ ùşéδ îή ťћε āġêʼnτ рâńё ťђăţ śùρρόřŧś ACP. ₣õř ťĥιѕ ряőƒїℓé, ţђĩş ѕéℓē¢ŧιбⁿ тåкёś φѓэĉзďелċ℮ öνёґ ťĥз ġĺôьåĺ Äĝёņţ ράπє ѕêłēçτíθŋ. !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! ! {Locked="ACP"} Description for the per-profile AI agent setting. + + Command palette agent + Header for choosing which AI agent handles command palette delegation for this profile. + + + Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. + Description for the per-profile command palette agent setting. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw index 19a296dece..ec0721b865 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw @@ -1247,6 +1247,14 @@ Čнοθśê ťнĕ ăĝĕήţ ùşéδ îή ťћε āġêʼnτ рâńё ťђăţ śùρρόřŧś ACP. ₣õř ťĥιѕ ряőƒїℓé, ţђĩş ѕéℓē¢ŧιбⁿ тåкёś φѓэĉзďелċ℮ öνёґ ťĥз ġĺôьåĺ Äĝёņţ ράπє ѕêłēçτíθŋ. !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! !!! ! {Locked="ACP"} Description for the per-profile AI agent setting. + + Command palette agent + Header for choosing which AI agent handles command palette delegation for this profile. + + + Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. + Description for the per-profile command palette agent setting. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw index 66e2cc7c73..0d5a998c3c 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/ru-RU/Resources.resw @@ -1245,6 +1245,14 @@ Выберите совместимого с ACP агента, который будет использоваться в панели агента. Для этого профиля этот выбор имеет приоритет над глобальным выбором агента для панели. {Locked="ACP"} Description for the per-profile AI agent setting. + + Агент для палитры команд + Header for choosing which AI agent handles command palette delegation for this profile. + + + Выберите агента, используемого для делегирования из палитры команд в этом профиле. Этот выбор имеет приоритет над глобальным параметром агента делегирования. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw index b2c5985339..1b00dc6715 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/sr-Cyrl-RS/Resources.resw @@ -1345,6 +1345,14 @@ Изаберите агента компатибилног са ACP који ће се користити у панелу агента. За овај профил, овај избор има предност над глобалним избором агента за панел. {Locked="ACP"} Description for the per-profile AI agent setting. + + Агент за палету команди + Header for choosing which AI agent handles command palette delegation for this profile. + + + Изаберите агента који се користи за делегирање из палете команди у овом профилу. Овај избор има предност над глобалном поставком агента за делегирање. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw index aed475f75a..11ec865b15 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/uk-UA/Resources.resw @@ -1261,6 +1261,14 @@ Виберіть сумісного з ACP агента, який використовуватиметься на панелі агента. Для цього профілю цей вибір має пріоритет над глобальним вибором агента для панелі. {Locked="ACP"} Description for the per-profile AI agent setting. + + Агент для палітри команд + Header for choosing which AI agent handles command palette delegation for this profile. + + + Виберіть агента, який використовується для делегування з палітри команд у цьому профілі. Цей вибір має пріоритет над глобальним параметром агента делегування. + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw index e543921d59..b16ebd9c97 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/zh-CN/Resources.resw @@ -1245,6 +1245,14 @@ 选择在智能体窗格中使用且支持 ACP 的智能体。对于此配置文件,此选择优先于全局智能体窗格选择。 {Locked="ACP"} Description for the per-profile AI agent setting. + + 命令面板智能体 + Header for choosing which AI agent handles command palette delegation for this profile. + + + 选择此配置文件中用于命令面板委派的智能体。此选择优先于全局委派智能体设置。 + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw index 6be3a1e057..01a7d84091 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/zh-TW/Resources.resw @@ -1245,6 +1245,14 @@ 選擇要在代理窗格中使用且支援 ACP 的代理。對於此設定檔,此選擇的優先順序高於全域代理窗格選擇。 {Locked="ACP"} Description for the per-profile AI agent setting. + + 命令選擇區代理 + Header for choosing which AI agent handles command palette delegation for this profile. + + + 選擇此設定檔中用於命令選擇區委派的代理。此選取項目優先於全域委派代理設定。 + Description for the per-profile command palette agent setting. Here “agent” means an AI agent, not a proxy or human agent. + {0} · {1} {Locked="{0}","{1}"} Agent display name replaces {0}; Windows or a WSL distro name replaces {1}. diff --git a/src/cascadia/TerminalSettingsModel/MTSMSettings.h b/src/cascadia/TerminalSettingsModel/MTSMSettings.h index bca386e5ee..361007f462 100644 --- a/src/cascadia/TerminalSettingsModel/MTSMSettings.h +++ b/src/cascadia/TerminalSettingsModel/MTSMSettings.h @@ -105,6 +105,7 @@ Author(s): X(Microsoft::Terminal::Control::TextAntialiasingMode, AntialiasingMode, "antialiasingMode", Microsoft::Terminal::Control::TextAntialiasingMode::Grayscale) \ X(hstring, StartingDirectory, "startingDirectory") \ X(hstring, AgentPaneBackend, "agentPaneBackend", L"") \ + X(hstring, CommandPaletteAgent, "commandPaletteAgent", L"") \ X(IMediaResource, Icon, "icon", implementation::MediaResource::FromString(L"\uE756")) \ X(bool, SuppressApplicationTitle, "suppressApplicationTitle", false) \ X(guid, ConnectionType, "connectionType") \ diff --git a/src/cascadia/TerminalSettingsModel/Profile.idl b/src/cascadia/TerminalSettingsModel/Profile.idl index 8695ee20a0..6c52ed5b9b 100644 --- a/src/cascadia/TerminalSettingsModel/Profile.idl +++ b/src/cascadia/TerminalSettingsModel/Profile.idl @@ -62,6 +62,7 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_PROFILE_SETTING(String, StartingDirectory); String EvaluatedStartingDirectory { get; }; INHERITABLE_PROFILE_SETTING(String, AgentPaneBackend); + INHERITABLE_PROFILE_SETTING(String, CommandPaletteAgent); FontConfig FontInfo { get; }; diff --git a/src/cascadia/UnitTests_SettingsModel/ProfileTests.cpp b/src/cascadia/UnitTests_SettingsModel/ProfileTests.cpp index 50653462f3..6335cadbae 100644 --- a/src/cascadia/UnitTests_SettingsModel/ProfileTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/ProfileTests.cpp @@ -670,7 +670,8 @@ namespace SettingsModelUnitTests static constexpr std::string_view userSettings{ R"({ "profiles": { "defaults": { - "agentPaneBackend": "host:claude" + "agentPaneBackend": "host:claude", + "commandPaletteAgent": "host:gemini" }, "list": [ { @@ -680,7 +681,8 @@ namespace SettingsModelUnitTests { "name": "wsl", "guid": "{6239a42c-1111-49a3-80bd-e8fdd045185c}", - "agentPaneBackend": "wsl:Ubuntu:copilot" + "agentPaneBackend": "wsl:Ubuntu:copilot", + "commandPaletteAgent": "wsl:Ubuntu:claude" } ] } @@ -691,11 +693,17 @@ namespace SettingsModelUnitTests VERIFY_ARE_EQUAL(L"host:claude", profiles.GetAt(0).AgentPaneBackend()); VERIFY_IS_FALSE(profiles.GetAt(0).HasAgentPaneBackend()); + VERIFY_ARE_EQUAL(L"host:gemini", profiles.GetAt(0).CommandPaletteAgent()); + VERIFY_IS_FALSE(profiles.GetAt(0).HasCommandPaletteAgent()); VERIFY_ARE_EQUAL(L"wsl:Ubuntu:copilot", profiles.GetAt(1).AgentPaneBackend()); VERIFY_IS_TRUE(profiles.GetAt(1).HasAgentPaneBackend()); + VERIFY_ARE_EQUAL(L"wsl:Ubuntu:claude", profiles.GetAt(1).CommandPaletteAgent()); + VERIFY_IS_TRUE(profiles.GetAt(1).HasCommandPaletteAgent()); profiles.GetAt(1).ClearAgentPaneBackend(); VERIFY_ARE_EQUAL(L"host:claude", profiles.GetAt(1).AgentPaneBackend()); + profiles.GetAt(1).ClearCommandPaletteAgent(); + VERIFY_ARE_EQUAL(L"host:gemini", profiles.GetAt(1).CommandPaletteAgent()); using ::Microsoft::Terminal::Settings::Model::AgentPaneBackend; using ::Microsoft::Terminal::Settings::Model::AgentPaneBackendSource; diff --git a/tools/wta/src/cli/args.rs b/tools/wta/src/cli/args.rs index aa61c92957..a88c48916d 100644 --- a/tools/wta/src/cli/args.rs +++ b/tools/wta/src/cli/args.rs @@ -345,6 +345,13 @@ pub(crate) enum Command { /// Model override for the delegate agent #[arg(long)] delegate_model: Option, + /// Exact execution source (host or wsl). Defaults to host when + /// omitted; never inferred from the active pane's shell/distro + #[arg(long)] + delegate_source: Option, + /// WSL distro for an explicit --delegate-source wsl selection + #[arg(long)] + delegate_wsl_distro: Option, /// Working directory for the delegate agent tab #[arg(long)] cwd: Option, diff --git a/tools/wta/src/cli/delegate.rs b/tools/wta/src/cli/delegate.rs index 1bf7f74b8e..871776398d 100644 --- a/tools/wta/src/cli/delegate.rs +++ b/tools/wta/src/cli/delegate.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::sync::Arc; +use crate::agent_source::AgentSource; use crate::shell::wt_channel::{CliChannel, WtChannel}; use crate::shell::ShellManager; @@ -9,16 +10,28 @@ pub(crate) async fn run( agent_cmd: &str, delegate_agent_cmd: Option<&str>, delegate_model: Option<&str>, + delegate_source: Option<&str>, + delegate_wsl_distro: Option<&str>, cwd: Option<&str>, ) -> Result<()> { // Log the prompt length, not the text — the prompt is user content. + // Log only the executable (first token) of agent_cmd, not the full + // command line with args — a custom agent command can embed + // tokens/credentials that shouldn't land in the log. + let agent_exe = crate::coordinator::split_windows_commandline(agent_cmd.trim()) + .into_iter() + .next() + .unwrap_or_default(); tracing::info!( prompt_chars = prompt.map(|p| p.chars().count()), - agent = agent_cmd, + agent = %agent_exe, "run_delegate started" ); tracing::trace!(target: "delegate.content", prompt = ?prompt, "run_delegate prompt"); + let requested_source = parse_delegate_source(delegate_source, delegate_wsl_distro)?; + require_delegate_agent_for_explicit_source(delegate_source, delegate_agent_cmd)?; + let (debug_tx, _) = tokio::sync::mpsc::unbounded_channel::(); let channel = match CliChannel::connect() .await @@ -41,6 +54,7 @@ pub(crate) async fn run( agent_cmd, delegate_agent_cmd, delegate_model, + &requested_source, cwd, ) .await @@ -58,21 +72,100 @@ pub(crate) async fn run( /// Whether the delegate agent CLI is actually available inside `distro`. /// -/// The probe runs under a login shell because common CLI installs only put the -/// agent on that PATH. Windows executables leaking in through WSL interop are -/// rejected by the shared probe, so failures safely fall back to the host CLI. +/// PR375 routes a `?` from a WSL pane into the distro +/// (`wsl -d -- bash -lc " …"`), but the agent may be +/// installed only on the Windows host — the Settings UI verifies the host +/// CLI, never the distro. Probe the distro under a **login** shell +/// (`bash -lc`): the shipped integration and the common CLI installs +/// (npm-global, snap, `~/.local/bin`) only put the agent on the login PATH, +/// so a non-login `bash -c` would miss it. The probe resolves the agent's +/// PATH location and accepts it only when it is a native Linux install — a +/// Windows CLI leaking in via `appendWindowsPath` (resolving under `/mnt/…`) +/// is rejected (see [`crate::agent_check::wsl_agent_probe_script`]). +/// This only gates an explicit `--delegate-source wsl` selection: an +/// unavailable agent keeps WSL as the source (see +/// [`delegate_launchable_for_source`]) so its command-not-found error +/// remains visible, rather than silently switching to the host. async fn wsl_delegate_agent_available(distro: &str, agent_exe: &str) -> bool { crate::agent_check::find_wsl_exe(distro, agent_exe) .await .is_some() } -/// Whether the delegate agent is launchable in either the host or WSL target. -pub(crate) fn delegate_launchable_for_target( +/// Parse `--delegate-source`/`--delegate-wsl-distro` into a concrete +/// [`AgentSource`]. +/// +/// Omitting `--delegate-source` defaults to `Host` — WTA never inspects the +/// active pane's shell/distro to pick a source and never routes to WSL +/// unless the caller asks for it explicitly. `--delegate-wsl-distro` is only +/// meaningful (and required) alongside an explicit `--delegate-source wsl`. +fn parse_delegate_source( + source: Option<&str>, + wsl_distro: Option<&str>, +) -> Result { + match source.map(str::trim) { + None => { + anyhow::ensure!( + wsl_distro.is_none(), + "--delegate-wsl-distro requires --delegate-source wsl" + ); + Ok(AgentSource::Host) + } + Some(source) if source.eq_ignore_ascii_case(AgentSource::HOST_KIND) => { + anyhow::ensure!( + wsl_distro.is_none(), + "--delegate-wsl-distro is invalid with --delegate-source host" + ); + Ok(AgentSource::Host) + } + Some(source) if source.eq_ignore_ascii_case(AgentSource::WSL_KIND) => { + let distro = wsl_distro + .map(str::trim) + .filter(|distro| !distro.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("--delegate-source wsl requires --delegate-wsl-distro") + })?; + Ok(AgentSource::Wsl { + distro: distro.to_string(), + }) + } + Some(source) => anyhow::bail!("unsupported delegate source '{source}'"), + } +} + +/// An explicit `--delegate-source` (host or wsl) commits the caller to a +/// specific agent command, so a bare source with no delegate agent is a +/// caller error rather than something to paper over with the `agent_cmd` +/// fallback used when the source is omitted entirely. +fn require_delegate_agent_for_explicit_source( + delegate_source: Option<&str>, + delegate_agent_cmd: Option<&str>, +) -> Result<()> { + anyhow::ensure!( + delegate_source.is_none() + || delegate_agent_cmd + .map(str::trim) + .filter(|command| !command.is_empty()) + .is_some(), + "--delegate-agent is required when --delegate-source is supplied" + ); + Ok(()) +} + +/// Whether the delegate agent is launchable for the caller's requested +/// execution target. An explicit `--delegate-source` selects exactly one +/// target — `Host` is gated on the Windows PATH check, `Wsl` is gated on the +/// in-distro probe — and neither ever substitutes for the other (no more +/// `host_launchable || wsl_agent_available` auto-routing). +pub(crate) fn delegate_launchable_for_source( + source: &AgentSource, host_launchable: bool, wsl_agent_available: bool, ) -> bool { - host_launchable || wsl_agent_available + match source { + AgentSource::Host => host_launchable, + AgentSource::Wsl { .. } => wsl_agent_available, + } } /// Max bytes of captured terminal context baked into a delegate prompt. @@ -97,13 +190,49 @@ fn cap_delegate_context(context: &str, max_bytes: usize) -> String { format!("{marker}{}", &context[start..]) } -/// Enrich a delegate prompt with pane context and launch it in a new tab. +/// Selects the POSIX cwd to record/use for an explicit WSL delegate launch. +/// +/// A WSL session's cwd must be a POSIX path (`/…`) — falling back without +/// validation to a Windows/UNC `--cwd` is misleading (the active pane may be +/// a Windows pane when `--delegate-source wsl` is forced) and breaks +/// downstream assumptions about WSL session cwd formatting. Trims whitespace +/// off each candidate, requires an absolute POSIX path (`/…`), and rejects +/// `"` (which would break the `wsl --cd ""` quoting). Prefers the +/// active pane's cwd over the explicit CLI `--cwd`; returns `None` if +/// neither is a valid POSIX path. The same value feeds both the `wsl --cd` +/// argument and `super::sessions::register_launched`, so the recorded +/// session cwd always matches the shell's actual working directory — never +/// the raw Windows cwd fallback. +fn select_wsl_delegate_cwd<'a>( + active_pane_cwd: Option<&'a str>, + explicit_cwd: Option<&'a str>, +) -> Option<&'a str> { + fn valid_posix_cwd(candidate: &str) -> Option<&str> { + let trimmed = candidate.trim(); + (trimmed.starts_with('/') && !trimmed.contains('"')).then_some(trimmed) + } + + active_pane_cwd + .and_then(valid_posix_cwd) + .or_else(|| explicit_cwd.and_then(valid_posix_cwd)) +} + +/// Shared delegation logic: enrich the prompt with the active pane's recent +/// output (when available), build the delegate-agent commandline, and create +/// a new tab to launch it. `requested_source` is always a concrete, +/// caller-chosen [`AgentSource`] — defaulting to `Host` when +/// `--delegate-source` is omitted (see `parse_delegate_source`). The active +/// pane is fetched only to enrich the prompt and to supply a WSL cwd +/// fallback; it never decides which target to launch into — WT's +/// GetActivePane already resolves the agent pane to the user's working +/// pane, so a single query is enough. async fn delegate_with_context( shell_mgr: &ShellManager, prompt: Option<&str>, agent_cmd: &str, delegate_agent_cmd: Option<&str>, delegate_model: Option<&str>, + requested_source: &AgentSource, cwd: Option<&str>, ) -> Result<()> { let delegate_agents = crate::coordinator::default_delegate_agent_runtimes( @@ -120,12 +249,22 @@ async fn delegate_with_context( // which could otherwise make arbitrary pane output alter cmd.exe parsing. let launchable = crate::coordinator::delegate_command_launchable(&runtime.commandline); - // Fetch the active pane before deciding whether the target is the Windows - // host or a WSL distro. + // Fetch the active pane up front — it feeds the enriched-prompt block and + // the WSL cwd lookup further down, not source selection: `requested_source` + // is the sole input to that decision (see `parse_delegate_source`), never + // the active pane's shell/distro. let active = shell_mgr.wt_get_active_pane().await.ok(); - let wsl_distro: Option = - crate::agent_source::active_pane_wsl_distro(active.as_ref()).map(str::to_string); - let wsl_agent_available = match wsl_distro.as_deref() { + + // `requested_source` is always a concrete, caller-chosen source (defaults + // to `Host` when `--delegate-source` is omitted; see + // `parse_delegate_source`). Probe WSL availability only for an explicit + // WSL selection — an unavailable agent keeps WSL as the source (never + // swaps to Host) so its command-not-found error stays visible. + let candidate_wsl_distro = match requested_source { + AgentSource::Host => None, + AgentSource::Wsl { distro } => Some(distro.as_str()), + }; + let wsl_agent_available = match candidate_wsl_distro { Some(distro) => { let agent_exe = crate::coordinator::split_windows_commandline(runtime.commandline.trim()) @@ -134,11 +273,11 @@ async fn delegate_with_context( .unwrap_or_default(); let available = wsl_delegate_agent_available(distro, &agent_exe).await; if !available { - tracing::info!( + tracing::warn!( target: "delegate", distro, agent = %agent_exe, - "delegate agent not available in WSL distro — falling back to Windows host CLI", + "selected delegate agent is unavailable in its WSL distro; keeping WSL as the source", ); } available @@ -146,7 +285,8 @@ async fn delegate_with_context( None => false, }; - let launchable_for_target = delegate_launchable_for_target(launchable, wsl_agent_available); + let launchable_for_target = + delegate_launchable_for_source(requested_source, launchable, wsl_agent_available); if !launchable_for_target { // Log only the executable: custom commands can contain credentials. @@ -219,72 +359,77 @@ async fn delegate_with_context( pinned_session_id.as_deref(), )?; - // Prefer an agent installed in the active WSL distro. The prompt is carried - // as a base64 payload by the coordinator so it survives both Windows and - // shell command-line parsing. - if wsl_agent_available { - if let (Some(distro), Some(active_pane)) = (wsl_distro.as_deref(), active.as_ref()) { - let wsl_agent_cmd = crate::coordinator::build_wsl_delegate_commandline( - runtime, - enriched_prompt.as_deref(), - pinned_session_id.as_deref(), - )?; - let escaped = crate::coordinator::quote_windows_commandline_arg(&wsl_agent_cmd); - let login_invocation = format!("bash -lc {}", escaped); - let distro_arg = crate::coordinator::quote_windows_commandline_arg(distro); - let wsl_cwd = active_pane - .get("cwd") - .and_then(|v| v.as_str()) - .filter(|s| s.starts_with('/') && !s.contains('"')); - let wsl_commandline = match wsl_cwd { - Some(cwd) => { - format!("wsl -d {distro_arg} --cd \"{cwd}\" -- {login_invocation}") - } - None => format!("wsl -d {distro_arg} -- {login_invocation}"), - }; - - tracing::debug!("delegate_with_context: launching in WSL ({distro})"); - tracing::trace!( - target: "delegate.content", - commandline = %wsl_commandline, - "wsl delegate commandline", - ); + // ── WSL delegate path ──────────────────────────────────────────────── + // An explicit `--delegate-source wsl` selection always stays in its + // configured distro. When the CLI is missing there, launch the selected + // command there anyway, without a prompt, so the new tab reports the + // real command-not-found error rather than silently switching to the + // host. + if let AgentSource::Wsl { distro } = requested_source { + let wsl_agent_cmd = crate::coordinator::build_wsl_delegate_commandline( + runtime, + enriched_prompt.as_deref(), + pinned_session_id.as_deref(), + )?; + let escaped = crate::coordinator::quote_windows_commandline_arg(&wsl_agent_cmd); + let login_invocation = format!("bash -lc {}", escaped); + let distro_arg = crate::coordinator::quote_windows_commandline_arg(distro); + let active_pane_cwd = active + .as_ref() + .and_then(|pane| pane.get("cwd")) + .and_then(|v| v.as_str()); + let wsl_cwd = select_wsl_delegate_cwd(active_pane_cwd, cwd); + let wsl_commandline = match wsl_cwd { + Some(cwd) => { + format!("wsl -d {distro_arg} --cd \"{cwd}\" -- {login_invocation}") + } + None => format!("wsl -d {distro_arg} -- {login_invocation}"), + }; + + tracing::debug!("delegate_with_context: launching in WSL ({distro})"); + tracing::trace!( + target: "delegate.content", + commandline = %wsl_commandline, + "wsl delegate commandline", + ); - let create_resp = shell_mgr - .wt_create_tab(Some(&wsl_commandline), None, None, None) - .await?; - let pane_guid = create_resp - .get("session_id") - .and_then(|v| v.as_str()) - .map(str::to_string); - tracing::info!( - target: "delegate", - pane_guid = ?pane_guid, - pinned = ?pinned_session_id, - distro, - "delegate WSL tab created", - ); + let create_resp = shell_mgr + .wt_create_tab(Some(&wsl_commandline), None, None, None) + .await?; + let pane_guid = create_resp + .get("session_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + tracing::info!( + target: "delegate", + pane_guid = ?pane_guid, + pinned = ?pinned_session_id, + distro, + "delegate WSL tab created", + ); - // WSL session tracking is independently feature-gated; launching - // the tab remains valid when tracking is disabled. - if crate::history_loader::wsl_sessions_enabled() { - if let (Some(sid), Some(pane)) = - (pinned_session_id.as_deref(), pane_guid.as_deref()) - { - super::sessions::register_launched( - sid, - pane, - &runtime.id, - wsl_cwd.or(cwd), - Some(distro), - ) + // Born-bound registration for the WSL delegate session — but only + // when WSL sessions are enabled. The whole WSL surface is gated on + // `WTA_WSL_SESSIONS`; with it off we must not surface any WSL + // session, born-bound delegate rows included. The tab still opens + // and the CLI still runs — it's just untracked, exactly like every + // other WSL session while the flag is off. + // + // `wsl_cwd` (the same POSIX-validated value used for `wsl --cd` + // above) is reused here rather than falling back to the raw + // Windows `cwd`, which would be misleading for a WSL session. + if crate::history_loader::wsl_sessions_enabled() { + if let (Some(sid), Some(pane)) = + (pinned_session_id.as_deref(), pane_guid.as_deref()) + { + super::sessions::register_launched(sid, pane, &runtime.id, wsl_cwd, Some(distro)) .await; - } } - return Ok(()); } + return Ok(()); } + // ── Windows (existing) path ────────────────────────────────────────── tracing::debug!("delegate_with_context: launching"); tracing::trace!(target: "delegate.content", commandline, "delegate_with_context commandline"); @@ -316,6 +461,7 @@ async fn delegate_with_context( #[cfg(test)] mod tests { use super::cap_delegate_context; + use crate::agent_source::AgentSource; #[test] fn cap_returns_short_context_unchanged() { @@ -349,4 +495,174 @@ mod tests { fn cap_omits_marker_when_limit_is_too_small() { assert_eq!(cap_delegate_context("prefix-tail", 4), "tail"); } + + // ── parse_delegate_source ──────────────────────────────────────────── + + #[test] + fn parse_delegate_source_defaults_to_host_when_omitted() { + let source = super::parse_delegate_source(None, None).expect("parses"); + assert_eq!(source, AgentSource::Host); + } + + #[test] + fn parse_delegate_source_rejects_distro_without_explicit_source() { + let err = super::parse_delegate_source(None, Some("Ubuntu")).unwrap_err(); + assert!(err.to_string().contains("requires --delegate-source wsl")); + assert!(super::parse_delegate_source(None, Some("")).is_err()); + assert!(super::parse_delegate_source(None, Some(" ")).is_err()); + } + + #[test] + fn parse_delegate_source_accepts_host_case_insensitively() { + let source = super::parse_delegate_source(Some("HOST"), None).expect("parses"); + assert_eq!(source, AgentSource::Host); + } + + #[test] + fn parse_delegate_source_rejects_distro_with_explicit_host() { + let err = super::parse_delegate_source(Some("host"), Some("Ubuntu")).unwrap_err(); + assert!(err.to_string().contains("invalid with --delegate-source host")); + assert!(super::parse_delegate_source(Some("host"), Some("")).is_err()); + assert!(super::parse_delegate_source(Some("host"), Some(" ")).is_err()); + } + + #[test] + fn parse_delegate_source_wsl_requires_nonempty_distro() { + let missing = super::parse_delegate_source(Some("wsl"), None).unwrap_err(); + assert!(missing + .to_string() + .contains("requires --delegate-wsl-distro")); + + let empty = super::parse_delegate_source(Some("wsl"), Some("")).unwrap_err(); + assert!(empty + .to_string() + .contains("requires --delegate-wsl-distro")); + + let whitespace = super::parse_delegate_source(Some("wsl"), Some(" ")).unwrap_err(); + assert!(whitespace + .to_string() + .contains("requires --delegate-wsl-distro")); + } + + #[test] + fn parse_delegate_source_wsl_trims_distro_name() { + let source = + super::parse_delegate_source(Some("wsl"), Some(" Ubuntu-22.04 ")).expect("parses"); + assert_eq!( + source, + AgentSource::Wsl { + distro: "Ubuntu-22.04".to_string() + } + ); + } + + #[test] + fn parse_delegate_source_rejects_unsupported_value() { + let err = super::parse_delegate_source(Some("mac"), None).unwrap_err(); + assert!(err.to_string().contains("unsupported delegate source")); + } + + // ── require_delegate_agent_for_explicit_source ────────────────────── + + #[test] + fn require_delegate_agent_allows_omitted_source_without_agent() { + assert!(super::require_delegate_agent_for_explicit_source(None, None).is_ok()); + } + + #[test] + fn require_delegate_agent_rejects_explicit_source_without_agent() { + assert!(super::require_delegate_agent_for_explicit_source(Some("host"), None).is_err()); + assert!( + super::require_delegate_agent_for_explicit_source(Some("wsl"), Some(" ")).is_err() + ); + } + + #[test] + fn require_delegate_agent_accepts_explicit_source_with_agent() { + assert!( + super::require_delegate_agent_for_explicit_source(Some("host"), Some("codex")).is_ok() + ); + } + + // ── delegate_launchable_for_source ────────────────────────────────── + + #[test] + fn delegate_launchable_for_source_host_never_switches_to_wsl() { + // An explicit Host selection is gated only on the host launchable + // flag — a WSL-available agent must never make an explicit Host + // request launchable. + assert!(!super::delegate_launchable_for_source( + &AgentSource::Host, + false, + true + )); + assert!(super::delegate_launchable_for_source( + &AgentSource::Host, + true, + false + )); + } + + #[test] + fn delegate_launchable_for_source_wsl_never_switches_to_host() { + // An explicit WSL selection is gated only on the in-distro probe — + // a host-launchable agent must never make an explicit WSL request + // launchable when the distro lacks it. + let wsl = AgentSource::Wsl { + distro: "Ubuntu".to_string(), + }; + assert!(!super::delegate_launchable_for_source(&wsl, true, false)); + assert!(super::delegate_launchable_for_source(&wsl, false, true)); + } + + // ── select_wsl_delegate_cwd ────────────────────────────────────────── + + #[test] + fn select_wsl_delegate_cwd_prefers_active_pane_over_explicit_cwd() { + assert_eq!( + super::select_wsl_delegate_cwd(Some("/home/active"), Some("/home/explicit")), + Some("/home/active") + ); + } + + #[test] + fn select_wsl_delegate_cwd_falls_back_to_explicit_cwd() { + assert_eq!( + super::select_wsl_delegate_cwd(None, Some("/home/explicit")), + Some("/home/explicit") + ); + } + + #[test] + fn select_wsl_delegate_cwd_trims_whitespace() { + assert_eq!( + super::select_wsl_delegate_cwd(Some(" /home/active "), None), + Some("/home/active") + ); + } + + #[test] + fn select_wsl_delegate_cwd_rejects_non_posix_and_quoted_paths() { + // Windows-style path is not a valid WSL cwd. + assert_eq!( + super::select_wsl_delegate_cwd(Some("C:\\Users\\me"), None), + None + ); + // A quote would break the `wsl --cd ""` quoting. + assert_eq!( + super::select_wsl_delegate_cwd(Some("/home/\"me\""), None), + None + ); + // Falls through to a valid explicit cwd when the active pane's is + // invalid. + assert_eq!( + super::select_wsl_delegate_cwd(Some("C:\\Users\\me"), Some("/home/explicit")), + Some("/home/explicit") + ); + // Neither candidate valid -> None (never the raw Windows fallback). + assert_eq!( + super::select_wsl_delegate_cwd(Some("C:\\Users\\me"), Some("D:\\other")), + None + ); + } } diff --git a/tools/wta/src/cli/mod.rs b/tools/wta/src/cli/mod.rs index ddae29fe0b..001ad117bd 100644 --- a/tools/wta/src/cli/mod.rs +++ b/tools/wta/src/cli/mod.rs @@ -40,6 +40,8 @@ pub(crate) async fn run(command: Command, json_mode: bool) -> Result<()> { agent, delegate_agent, delegate_model, + delegate_source, + delegate_wsl_distro, cwd, } => { delegate::run( @@ -47,6 +49,8 @@ pub(crate) async fn run(command: Command, json_mode: bool) -> Result<()> { &agent, delegate_agent.as_deref(), delegate_model.as_deref(), + delegate_source.as_deref(), + delegate_wsl_distro.as_deref(), cwd.as_deref(), ) .await diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index 95e6618557..83fe7fdaa9 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -248,6 +248,51 @@ fn process_label_subcommands() { assert_eq!(process_label(&sessions), "cli"); } +// ── Command::Delegate: --delegate-source / --delegate-wsl-distro parsing ──── + +#[test] +fn delegate_command_parses_explicit_source_and_distro() { + let cli = Cli::try_parse_from([ + "wta", + "delegate", + "--delegate-agent", + "codex", + "--delegate-source", + "wsl", + "--delegate-wsl-distro", + "Ubuntu", + "do a thing", + ]) + .expect("flags must parse"); + match cli.command { + Some(Command::Delegate { + delegate_source, + delegate_wsl_distro, + .. + }) => { + assert_eq!(delegate_source.as_deref(), Some("wsl")); + assert_eq!(delegate_wsl_distro.as_deref(), Some("Ubuntu")); + } + other => panic!("expected Command::Delegate, got {other:?}"), + } +} + +#[test] +fn delegate_command_source_and_distro_default_to_none() { + let cli = Cli::try_parse_from(["wta", "delegate", "do a thing"]).expect("flags must parse"); + match cli.command { + Some(Command::Delegate { + delegate_source, + delegate_wsl_distro, + .. + }) => { + assert!(delegate_source.is_none()); + assert!(delegate_wsl_distro.is_none()); + } + other => panic!("expected Command::Delegate, got {other:?}"), + } +} + // ── HooksCliFilter::into_scope: CLI filter → installer scope ───────────────── #[test] @@ -289,15 +334,16 @@ fn json_str_or_num_reads_strings_and_numbers_else_dash() { assert_eq!(cli::wt::json_str_or_num(&v, "missing"), "-"); } -// ── Delegate: WSL pane target detection + launchable gate ─────────────────── +// ── `/agent` source picker: WSL pane detection ────────────────────────────── // -// `delegate_command_launchable` only checks the Windows PATH, which is -// meaningless for a WSL pane (the agent runs inside the distro). A WSL pane is -// therefore treated as launchable when the agent CLI is present *inside the -// distro* — so a `?` from a WSL pane still gets its prompt -// enriched/delivered when the agent (e.g. Copilot) is installed only inside the -// distro (regression guard for the "prompt silently dropped" bug), while a WSL -// pane whose distro lacks the CLI falls back to the Windows host term. +// `active_pane_wsl_distro` detects whether the active pane is a WSL shell so +// the `/agent` command-palette source picker can offer that distro's ACP +// agents alongside the host ones (see `App::request_agent_source_picker` in +// `app.rs`). `wta delegate` no longer uses this helper for source selection — +// its explicit `--delegate-source`/`--delegate-wsl-distro` flags own that +// decision (see `cli::delegate::parse_delegate_source` and its tests in +// `cli/delegate.rs`); this detector remains covered here because the `/agent` +// picker still depends on it. /// Build a minimal active-pane JSON value with the given `shell` field, as /// reported by WT's `get_active_pane` / `OSC 9001;ShellType`. @@ -375,19 +421,8 @@ fn wsl_agent_probe_script_prints_command_v_resolution() { ); } -#[test] -fn delegate_launchable_for_target_ors_host_and_wsl() { - // Agent not launchable on the Windows host, but present inside the WSL - // distro → launchable (in-distro path), so the prompt is enriched, not - // dropped. - assert!(cli::delegate::delegate_launchable_for_target(false, true)); - - // Not launchable on host AND not available in WSL → stays non-launchable - // (the bare-command path, where the prompt is intentionally not baked in). - // Covers a non-WSL pane and a WSL pane whose distro lacks the CLI alike. - assert!(!cli::delegate::delegate_launchable_for_target(false, false)); - - // Launchable on the host is always launchable, regardless of WSL. - assert!(cli::delegate::delegate_launchable_for_target(true, false)); - assert!(cli::delegate::delegate_launchable_for_target(true, true)); -} +// `wta delegate`'s own launchability semantics (explicit +// `--delegate-source`, never auto-routed) are covered by the module-private +// tests in `cli/delegate.rs`, alongside its `parse_delegate_source` / +// `select_wsl_delegate_cwd` coverage. +