From 212e6447fcc2a72baf077f20edf5387822b12d65 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 14:57:17 +0800 Subject: [PATCH 1/9] Add profile-scoped command palette agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- README.md | 4 + doc/cascadia/profiles.schema.json | 8 + src/cascadia/TerminalApp/TerminalPage.cpp | 91 ++++-- .../ProfileViewModel.cpp | 242 +++++++++----- .../TerminalSettingsEditor/ProfileViewModel.h | 9 +- .../ProfileViewModel.idl | 3 + .../TerminalSettingsEditor/Profiles_Base.xaml | 14 + .../Resources/de-DE/Resources.resw | 8 + .../Resources/en-US/Resources.resw | 8 + .../Resources/es-ES/Resources.resw | 8 + .../Resources/fr-FR/Resources.resw | 8 + .../Resources/it-IT/Resources.resw | 8 + .../Resources/ja-JP/Resources.resw | 8 + .../Resources/ko-KR/Resources.resw | 8 + .../Resources/pt-BR/Resources.resw | 8 + .../Resources/qps-ploc/Resources.resw | 8 + .../Resources/qps-ploca/Resources.resw | 8 + .../Resources/qps-plocm/Resources.resw | 8 + .../Resources/ru-RU/Resources.resw | 8 + .../Resources/sr-Cyrl-RS/Resources.resw | 8 + .../Resources/uk-UA/Resources.resw | 8 + .../Resources/zh-CN/Resources.resw | 8 + .../Resources/zh-TW/Resources.resw | 8 + .../TerminalSettingsModel/MTSMSettings.h | 1 + .../TerminalSettingsModel/Profile.idl | 1 + .../UnitTests_SettingsModel/ProfileTests.cpp | 12 +- tools/wta/src/cli_tests.rs | 80 +++-- tools/wta/src/main.rs | 301 +++++++++++------- 28 files changed, 653 insertions(+), 241 deletions(-) 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 376d088953..294681a1c0 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1318,28 +1318,76 @@ 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; + }); + if (!knownAndAllowed) + { + delegateAgent = {}; + } + else + { + delegateAgent = winrt::hstring{ backend->agentId }; + delegateModel = {}; + 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 command palette 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 +1398,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 +1420,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 +2022,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..56caf9d930 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. + {Locked=qps-ploc,qps-ploca,qps-plocm} 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..56caf9d930 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. + {Locked=qps-ploc,qps-ploca,qps-plocm} 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..56caf9d930 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. + {Locked=qps-ploc,qps-ploca,qps-plocm} 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_tests.rs b/tools/wta/src/cli_tests.rs index 9fc9518ab0..d650539335 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -397,15 +397,14 @@ fn json_str_or_num_reads_strings_and_numbers_else_dash() { assert_eq!(json_str_or_num(&v, "missing"), "-"); } -// ── Delegate: WSL pane target detection + launchable gate ─────────────────── +// ── Delegate: profile-selected execution source ───────────────────────────── // // `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. +// distro. A profile-selected source is strict: it stays on that source even +// when unavailable, so the selected command reports its own launch error +// instead of falling back. /// Build a minimal active-pane JSON value with the given `shell` field, as /// reported by WT's `get_active_pane` / `OSC 9001;ShellType`. @@ -484,18 +483,61 @@ 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!(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!(!delegate_launchable_for_target(false, false)); - - // Launchable on the host is always launchable, regardless of WSL. - assert!(delegate_launchable_for_target(true, false)); - assert!(delegate_launchable_for_target(true, true)); +fn delegate_source_args_require_a_valid_exact_target() { + use crate::agent_source::AgentSource; + + assert_eq!( + parse_delegate_source(Some("host"), None).unwrap(), + Some(AgentSource::Host) + ); + assert_eq!( + parse_delegate_source(Some("wsl"), Some("Ubuntu")).unwrap(), + Some(AgentSource::Wsl { + distro: "Ubuntu".to_string() + }) + ); + assert!(parse_delegate_source(Some("wsl"), None).is_err()); + assert!(parse_delegate_source(Some("host"), Some("Ubuntu")).is_err()); + assert!(parse_delegate_source(None, Some("Ubuntu")).is_err()); + assert!(parse_delegate_source(Some("remote"), None).is_err()); +} + +#[test] +fn explicit_delegate_source_never_falls_back() { + use crate::agent_source::AgentSource; + + let wsl = AgentSource::Wsl { + distro: "Ubuntu".to_string(), + }; + assert_eq!( + delegate_launch_source(Some(&wsl), Some("Ubuntu"), false), + wsl + ); + assert!(!delegate_launchable_for_source(&wsl, true, false)); + + assert_eq!( + delegate_launch_source(Some(&AgentSource::Host), Some("Ubuntu"), true), + AgentSource::Host + ); + assert!(!delegate_launchable_for_source( + &AgentSource::Host, + false, + true + )); +} + +#[test] +fn legacy_delegate_source_auto_routes_only_to_available_wsl_agent() { + use crate::agent_source::AgentSource; + + assert_eq!( + delegate_launch_source(None, Some("Ubuntu"), true), + AgentSource::Wsl { + distro: "Ubuntu".to_string() + } + ); + assert_eq!( + delegate_launch_source(None, Some("Ubuntu"), false), + AgentSource::Host + ); } diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 361f89a1c2..79737a9932 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -506,6 +506,14 @@ enum Command { #[arg(long)] delegate_model: Option, + /// Exact execution source selected by the active profile (host or wsl) + #[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, @@ -973,6 +981,8 @@ async fn main() -> Result<()> { agent, delegate_agent, delegate_model, + delegate_source, + delegate_wsl_distro, cwd, }) => { run_delegate( @@ -980,6 +990,8 @@ async fn main() -> Result<()> { &agent, delegate_agent.as_deref(), delegate_model.as_deref(), + delegate_source.as_deref(), + delegate_wsl_distro.as_deref(), cwd.as_deref(), ) .await @@ -2133,6 +2145,8 @@ async fn run_delegate( 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. @@ -2152,6 +2166,7 @@ async fn run_delegate( }; let shell_mgr = ShellManager::new() .with_wt_channel(Arc::new(channel) as Arc); + let requested_source = parse_delegate_source(delegate_source, delegate_wsl_distro)?; match delegate_with_context( &shell_mgr, @@ -2159,6 +2174,7 @@ async fn run_delegate( agent_cmd, delegate_agent_cmd, delegate_model, + requested_source.as_ref(), cwd, ) .await @@ -2192,32 +2208,76 @@ async fn run_delegate( /// 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, so it falls back to the host CLI that -/// can actually run it (see [`wsl_agent_probe_script`]). Returns `false` on any -/// spawn/exec error or timeout so the caller falls back to the known-good -/// Windows host CLI instead of launching a doomed in-distro command that would -/// silently drop the prompt. +/// (resolving under `/mnt/…`) is rejected (see [`wsl_agent_probe_script`]). +/// Legacy auto-routing may fall back to the host; an explicit profile source +/// keeps the selected distro so its command-not-found error remains visible. 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 should be treated as launchable for the active -/// pane's *target* environment. -/// -/// `host_launchable` comes from [`crate::coordinator::delegate_command_launchable`], -/// which only inspects the Windows PATH. `wsl_agent_available` is true when the -/// active pane is a WSL distro **and** the agent CLI is installed inside it (see -/// [`wsl_delegate_agent_available`]). Either path makes the delegate -/// launchable: the Windows host, or the in-distro CLI. Without the WSL term a -/// Copilot/Claude installed only in the distro would be treated as -/// non-launchable and silently drop its `?` text; with it, a WSL pane -/// whose distro lacks the CLI still falls through to the host term rather than -/// being force-routed into a doomed in-distro launch. The prompt-enrichment and -/// session-pin gates in `delegate_with_context` both key off this value. -fn delegate_launchable_for_target(host_launchable: bool, wsl_agent_available: bool) -> bool { - host_launchable || wsl_agent_available +fn parse_delegate_source( + source: Option<&str>, + wsl_distro: Option<&str>, +) -> Result> { + use crate::agent_source::AgentSource; + + match source.map(str::trim) { + None => { + anyhow::ensure!( + wsl_distro.map(str::trim).filter(|distro| !distro.is_empty()).is_none(), + "--delegate-wsl-distro requires --delegate-source wsl" + ); + Ok(None) + } + Some(source) if source.eq_ignore_ascii_case(AgentSource::HOST_KIND) => { + anyhow::ensure!( + wsl_distro.map(str::trim).filter(|distro| !distro.is_empty()).is_none(), + "--delegate-wsl-distro is invalid with --delegate-source host" + ); + Ok(Some(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(Some(AgentSource::Wsl { + distro: distro.to_string(), + })) + } + Some(source) => anyhow::bail!("unsupported delegate source '{source}'"), + } +} + +fn delegate_launch_source( + requested: Option<&crate::agent_source::AgentSource>, + active_wsl_distro: Option<&str>, + wsl_agent_available: bool, +) -> crate::agent_source::AgentSource { + use crate::agent_source::AgentSource; + + match requested { + Some(source) => source.clone(), + None if wsl_agent_available => active_wsl_distro + .map(|distro| AgentSource::Wsl { + distro: distro.to_string(), + }) + .unwrap_or(AgentSource::Host), + None => AgentSource::Host, + } +} + +fn delegate_launchable_for_source( + source: &crate::agent_source::AgentSource, + host_launchable: bool, + wsl_agent_available: bool, +) -> bool { + match source { + crate::agent_source::AgentSource::Host => host_launchable, + crate::agent_source::AgentSource::Wsl { .. } => wsl_agent_available, + } } /// Max bytes of captured terminal context baked into a delegate prompt. @@ -2261,8 +2321,17 @@ async fn delegate_with_context( agent_cmd: &str, delegate_agent_cmd: Option<&str>, delegate_model: Option<&str>, + requested_source: Option<&crate::agent_source::AgentSource>, cwd: Option<&str>, ) -> Result<()> { + if requested_source.is_some() + && delegate_agent_cmd + .map(str::trim) + .filter(|command| !command.is_empty()) + .is_none() + { + anyhow::bail!("no command palette agent configured"); + } let delegate_agents = crate::coordinator::default_delegate_agent_runtimes( delegate_agent_cmd, Some(agent_cmd), @@ -2292,18 +2361,20 @@ async fn delegate_with_context( // A WSL pane runs the agent *inside the distro* (`wsl -d -- …`), so // the Windows-host launchable check does not apply to it. Fetch the active // pane up front so the gate below and the WSL branch further down can see - // it. See `delegate_launchable_for_target`. + // it. See `delegate_launchable_for_source`. let active = shell_mgr.wt_get_active_pane().await.ok(); - // If the active pane is a WSL distro, prefer running the agent inside it — - // but only when the agent CLI is actually installed there. Otherwise, fall - // back to the Windows host CLI (which the Settings UI already verified is - // installed): an in-distro launch would just print ": command not - // found" and drop the prompt. Probe the distro once, up front, so the - // launchable gate, the WSL branch, and the host fallback all agree. - let wsl_distro: Option = + // Legacy callers without an explicit source retain active-WSL detection. + // Profile-scoped callers provide an exact host/WSL source, which is never + // replaced when its selected agent is unavailable. + let active_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() { + let candidate_wsl_distro = match requested_source { + Some(crate::agent_source::AgentSource::Host) => None, + Some(crate::agent_source::AgentSource::Wsl { distro }) => Some(distro.as_str()), + None => active_wsl_distro.as_deref(), + }; + let wsl_agent_available = match candidate_wsl_distro { Some(distro) => { let agent_exe = crate::coordinator::split_windows_commandline(runtime.commandline.trim()) @@ -2312,19 +2383,34 @@ async fn delegate_with_context( .unwrap_or_default(); let available = wsl_delegate_agent_available(distro, &agent_exe).await; if !available { - tracing::info!( - target: "delegate", - distro, - agent = %agent_exe, - "delegate agent not available in WSL distro — falling back to Windows host CLI", - ); + if requested_source.is_some() { + tracing::warn!( + target: "delegate", + distro, + agent = %agent_exe, + "selected command palette agent is unavailable in its WSL distro; keeping the selected source", + ); + } else { + tracing::info!( + target: "delegate", + distro, + agent = %agent_exe, + "delegate agent not available in WSL distro — falling back to Windows host CLI", + ); + } } available } None => false, }; - let launchable_for_target = delegate_launchable_for_target(launchable, wsl_agent_available); + let launch_source = delegate_launch_source( + requested_source, + active_wsl_distro.as_deref(), + wsl_agent_available, + ); + let launchable_for_target = + delegate_launchable_for_source(&launch_source, launchable, wsl_agent_available); if !launchable_for_target { // Log only the executable (first token), never the full commandline: a @@ -2417,11 +2503,10 @@ async fn delegate_with_context( )?; // ── WSL delegate path ─────────────────────────────────────────────────── - // Taken only when the active pane is a WSL distro AND the agent CLI is - // installed inside it (`wsl_agent_available`). Build a WSL-native command - // that runs the agent CLI inside the distro (using the Linux toolchain and - // filesystem). When the distro lacks the CLI we fall through to the Windows - // host path below, which sanitizes the pane's POSIX cwd to the Windows home. + // An explicit profile selection always stays in its configured distro. + // When the CLI is missing, launch the selected command there without a + // prompt so the new tab reports the real command-not-found error. Legacy + // auto-routing reaches this path only after a successful WSL probe. // // Delivery (see `build_wsl_delegate_commandline`): the prompt rides as an // inline base64 payload decoded in-distro — base64's alphabet has no shell @@ -2436,80 +2521,76 @@ async fn delegate_with_context( // // Composability works because the two layers have disjoint special // characters: ' is special to bash, " is special to Windows. - if wsl_agent_available { - // `wsl_agent_available` implies both `wsl_distro` and `active` are set - // (it is derived from them above); the `if let` is a defensive guard - // that falls through to the host path in the impossible None case. - 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}"), - }; + if let crate::agent_source::AgentSource::Wsl { distro } = &launch_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 wsl_cwd = active + .as_ref() + .and_then(|pane| 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", - ); + 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", + ); - // 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 master-side - // historical WSL scan is already gated, so skipping this registration - // keeps a `?` WSL delegate out of the session view). The tab - // still opens and the CLI still runs — it's just untracked, exactly - // like every other WSL session while the flag is off. - // - // The distro is threaded through so the master stamps the row - // `Wsl { distro }` → the session view shows the `[WSL-]` - // prefix. - if crate::history_loader::wsl_sessions_enabled() { - if let (Some(sid), Some(pane)) = - (pinned_session_id.as_deref(), pane_guid.as_deref()) - { - register_launched_session_with_master( - sid, - pane, - &runtime.id, - wsl_cwd.or(cwd), - Some(distro), - ) - .await; - } + // 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 master-side + // historical WSL scan is already gated, so skipping this registration + // keeps a `?` WSL delegate out of the session view). The tab + // still opens and the CLI still runs — it's just untracked, exactly + // like every other WSL session while the flag is off. + // + // The distro is threaded through so the master stamps the row + // `Wsl { distro }` → the session view shows the `[WSL-]` + // prefix. + if crate::history_loader::wsl_sessions_enabled() { + if let (Some(sid), Some(pane)) = + (pinned_session_id.as_deref(), pane_guid.as_deref()) + { + register_launched_session_with_master( + sid, + pane, + &runtime.id, + wsl_cwd.or(cwd), + Some(distro), + ) + .await; } - return Ok(()); } + return Ok(()); } // ── Windows (existing) path ──────────────────────────────────────────── From 2eecac7b2d4db3141be91096fc4c5c5ae912c7af Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 15:10:24 +0800 Subject: [PATCH 2/9] Clarify strict profile delegate selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- src/cascadia/TerminalApp/TerminalPage.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 294681a1c0..1d6d3711d1 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1347,6 +1347,8 @@ namespace winrt::TerminalApp::implementation [&](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 = {}; From 06ca05e75110b0d20d1c8289f9c777afac86674e Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 15:37:13 +0800 Subject: [PATCH 3/9] Address delegate review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- .../TerminalSettingsEditor/Resources/qps-ploc/Resources.resw | 2 +- .../TerminalSettingsEditor/Resources/qps-ploca/Resources.resw | 2 +- .../TerminalSettingsEditor/Resources/qps-plocm/Resources.resw | 2 +- tools/wta/src/main.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw index 56caf9d930..ec0721b865 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploc/Resources.resw @@ -1253,7 +1253,7 @@ Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. - {Locked=qps-ploc,qps-ploca,qps-plocm} Description for the per-profile command palette agent setting. + Description for the per-profile command palette agent setting. {0} · {1} diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw index 56caf9d930..ec0721b865 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-ploca/Resources.resw @@ -1253,7 +1253,7 @@ Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. - {Locked=qps-ploc,qps-ploca,qps-plocm} Description for the per-profile command palette agent setting. + Description for the per-profile command palette agent setting. {0} · {1} diff --git a/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw index 56caf9d930..ec0721b865 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/qps-plocm/Resources.resw @@ -1253,7 +1253,7 @@ Choose the agent used for command palette delegation in this profile. This selection takes precedence over the global delegate agent setting. - {Locked=qps-ploc,qps-ploca,qps-plocm} Description for the per-profile command palette agent setting. + Description for the per-profile command palette agent setting. {0} · {1} diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 79737a9932..acebf86057 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -2330,7 +2330,7 @@ async fn delegate_with_context( .filter(|command| !command.is_empty()) .is_none() { - anyhow::bail!("no command palette agent configured"); + anyhow::bail!("--delegate-agent is required when --delegate-source is supplied"); } let delegate_agents = crate::coordinator::default_delegate_agent_runtimes( delegate_agent_cmd, From c4bf750976ef37a567ff803e2120a33fcd463fcf Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 15:54:10 +0800 Subject: [PATCH 4/9] Use generic delegate diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- tools/wta/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index acebf86057..f54c8061f2 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -2388,7 +2388,7 @@ async fn delegate_with_context( target: "delegate", distro, agent = %agent_exe, - "selected command palette agent is unavailable in its WSL distro; keeping the selected source", + "selected delegate agent is unavailable in its WSL distro; keeping the selected source", ); } else { tracing::info!( From 0b7cd4b8bfa39352db0c7cb9faaae372773ce615 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 16:44:48 +0800 Subject: [PATCH 5/9] Make profile delegate routing authoritative Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- src/cascadia/TerminalApp/TerminalPage.cpp | 5 +- tools/wta/src/cli_tests.rs | 85 +++++++------ tools/wta/src/main.rs | 143 ++++++++++------------ 3 files changed, 116 insertions(+), 117 deletions(-) diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 1d6d3711d1..d2b64810f5 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1356,7 +1356,10 @@ namespace winrt::TerminalApp::implementation else { delegateAgent = winrt::hstring{ backend->agentId }; - delegateModel = {}; + // 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"; diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index d650539335..012636a8b5 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -397,17 +397,22 @@ fn json_str_or_num_reads_strings_and_numbers_else_dash() { assert_eq!(json_str_or_num(&v, "missing"), "-"); } -// ── Delegate: profile-selected execution source ───────────────────────────── +// ── Delegate: execution source ─────────────────────────────────────────────── +// +// `--delegate-source` defaults to `host` when omitted and is never inferred +// from the active pane's shell/distro. An explicit `host` or `wsl` selection +// is strict: it never switches based on CLI availability, so the selected +// command reports its own launch error instead of silently falling back. // // `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. A profile-selected source is strict: it stays on that source even -// when unavailable, so the selected command reports its own launch error -// instead of falling back. +// meaningless for a WSL pane (the agent runs inside the distro), so a WSL +// source is treated as launchable only when the agent CLI is present *inside +// the distro* (`delegate_launchable_for_source`). /// Build a minimal active-pane JSON value with the given `shell` field, as -/// reported by WT's `get_active_pane` / `OSC 9001;ShellType`. +/// reported by WT's `get_active_pane` / `OSC 9001;ShellType`. Used only by +/// `active_pane_wsl_distro` tests below — that helper now backs the `/agent` +/// source picker (see `app.rs`), not delegate source selection. fn pane_with_shell(shell: &str) -> serde_json::Value { serde_json::json!({ "shell": shell }) } @@ -486,15 +491,21 @@ fn wsl_agent_probe_script_prints_command_v_resolution() { fn delegate_source_args_require_a_valid_exact_target() { use crate::agent_source::AgentSource; + // Omitting --delegate-source defaults to Host — WTA never inspects the + // active pane's shell/distro to pick a source. + assert_eq!( + parse_delegate_source(None, None).unwrap(), + AgentSource::Host + ); assert_eq!( parse_delegate_source(Some("host"), None).unwrap(), - Some(AgentSource::Host) + AgentSource::Host ); assert_eq!( parse_delegate_source(Some("wsl"), Some("Ubuntu")).unwrap(), - Some(AgentSource::Wsl { + AgentSource::Wsl { distro: "Ubuntu".to_string() - }) + } ); assert!(parse_delegate_source(Some("wsl"), None).is_err()); assert!(parse_delegate_source(Some("host"), Some("Ubuntu")).is_err()); @@ -503,41 +514,41 @@ fn delegate_source_args_require_a_valid_exact_target() { } #[test] -fn explicit_delegate_source_never_falls_back() { +fn delegate_agent_required_only_when_source_is_explicit() { + // Omitted --delegate-source: no --delegate-agent required (the + // `agent_cmd` fallback covers it). + assert!(require_delegate_agent_for_explicit_source(None, None).is_ok()); + // Explicit --delegate-source (host or wsl) requires --delegate-agent. + assert!(require_delegate_agent_for_explicit_source(Some("host"), None).is_err()); + assert!(require_delegate_agent_for_explicit_source(Some("host"), Some(" ")).is_err()); + assert!(require_delegate_agent_for_explicit_source(Some("wsl"), None).is_err()); + assert!(require_delegate_agent_for_explicit_source(Some("host"), Some("codex")).is_ok()); + assert!(require_delegate_agent_for_explicit_source(Some("wsl"), Some("codex")).is_ok()); +} + +#[test] +fn delegate_source_never_switches_based_on_wsl_agent_availability() { use crate::agent_source::AgentSource; let wsl = AgentSource::Wsl { distro: "Ubuntu".to_string(), }; - assert_eq!( - delegate_launch_source(Some(&wsl), Some("Ubuntu"), false), - wsl - ); - assert!(!delegate_launchable_for_source(&wsl, true, false)); + // An explicit WSL selection stays WSL even when its agent is missing in + // the distro — no fallback to the host. + assert!(!delegate_launchable_for_source( + &wsl, + /* host_launchable */ true, + /* wsl_agent_available */ false + )); + assert!(delegate_launchable_for_source(&wsl, false, true)); - assert_eq!( - delegate_launch_source(Some(&AgentSource::Host), Some("Ubuntu"), true), - AgentSource::Host - ); + // An explicit (or defaulted) Host selection stays Host even when a WSL + // agent happens to be available — no auto-routing into WSL. assert!(!delegate_launchable_for_source( &AgentSource::Host, - false, - true + /* host_launchable */ false, + /* wsl_agent_available */ true )); + assert!(delegate_launchable_for_source(&AgentSource::Host, true, false)); } -#[test] -fn legacy_delegate_source_auto_routes_only_to_available_wsl_agent() { - use crate::agent_source::AgentSource; - - assert_eq!( - delegate_launch_source(None, Some("Ubuntu"), true), - AgentSource::Wsl { - distro: "Ubuntu".to_string() - } - ); - assert_eq!( - delegate_launch_source(None, Some("Ubuntu"), false), - AgentSource::Host - ); -} diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index f54c8061f2..070043b399 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -506,7 +506,8 @@ enum Command { #[arg(long)] delegate_model: Option, - /// Exact execution source selected by the active profile (host or wsl) + /// 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, @@ -2153,6 +2154,9 @@ async fn run_delegate( tracing::info!(prompt_chars = prompt.map(|p| p.chars().count()), agent = agent_cmd, "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 connect_to_wt_protocol(debug_tx).await { Ok(ch) => { @@ -2166,7 +2170,6 @@ async fn run_delegate( }; let shell_mgr = ShellManager::new() .with_wt_channel(Arc::new(channel) as Arc); - let requested_source = parse_delegate_source(delegate_source, delegate_wsl_distro)?; match delegate_with_context( &shell_mgr, @@ -2174,7 +2177,7 @@ async fn run_delegate( agent_cmd, delegate_agent_cmd, delegate_model, - requested_source.as_ref(), + &requested_source, cwd, ) .await @@ -2190,14 +2193,6 @@ async fn run_delegate( } } -/// The WSL distro backing the delegate's active pane, if any — i.e. its shell, -/// reported via `OSC 9001;ShellType`, is `wsl:` with a **non-empty** -/// distro name (e.g. `wsl:Ubuntu`). The shipped Bash shell integration only -/// emits `wsl:` when `$WSL_DISTRO_NAME` is set (otherwise it reports -/// `bash`), so a bare `wsl:` never occurs in practice; rejecting it defensively -/// keeps us from ever building a `wsl -d "" …` command. Returns `None` when the -/// pane is missing, has no `shell` field, or the shell is anything else -/// (PowerShell, cmd, …). /// Whether the delegate agent CLI is actually available inside `distro`. /// /// PR375 routes a `?` from a WSL pane into the distro @@ -2209,18 +2204,27 @@ async fn run_delegate( /// 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 [`wsl_agent_probe_script`]). -/// Legacy auto-routing may fall back to the host; an explicit profile source -/// keeps the selected distro so its command-not-found error remains visible. +/// 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() } +/// Parse `--delegate-source`/`--delegate-wsl-distro` into a concrete +/// [`AgentSource`](crate::agent_source::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> { +) -> Result { use crate::agent_source::AgentSource; match source.map(str::trim) { @@ -2229,44 +2233,45 @@ fn parse_delegate_source( wsl_distro.map(str::trim).filter(|distro| !distro.is_empty()).is_none(), "--delegate-wsl-distro requires --delegate-source wsl" ); - Ok(None) + Ok(AgentSource::Host) } Some(source) if source.eq_ignore_ascii_case(AgentSource::HOST_KIND) => { anyhow::ensure!( wsl_distro.map(str::trim).filter(|distro| !distro.is_empty()).is_none(), "--delegate-wsl-distro is invalid with --delegate-source host" ); - Ok(Some(AgentSource::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(Some(AgentSource::Wsl { + Ok(AgentSource::Wsl { distro: distro.to_string(), - })) + }) } Some(source) => anyhow::bail!("unsupported delegate source '{source}'"), } } -fn delegate_launch_source( - requested: Option<&crate::agent_source::AgentSource>, - active_wsl_distro: Option<&str>, - wsl_agent_available: bool, -) -> crate::agent_source::AgentSource { - use crate::agent_source::AgentSource; - - match requested { - Some(source) => source.clone(), - None if wsl_agent_available => active_wsl_distro - .map(|distro| AgentSource::Wsl { - distro: distro.to_string(), - }) - .unwrap_or(AgentSource::Host), - None => AgentSource::Host, - } +/// 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(()) } fn delegate_launchable_for_source( @@ -2321,17 +2326,9 @@ async fn delegate_with_context( agent_cmd: &str, delegate_agent_cmd: Option<&str>, delegate_model: Option<&str>, - requested_source: Option<&crate::agent_source::AgentSource>, + requested_source: &crate::agent_source::AgentSource, cwd: Option<&str>, ) -> Result<()> { - if requested_source.is_some() - && delegate_agent_cmd - .map(str::trim) - .filter(|command| !command.is_empty()) - .is_none() - { - anyhow::bail!("--delegate-agent is required when --delegate-source is supplied"); - } let delegate_agents = crate::coordinator::default_delegate_agent_runtimes( delegate_agent_cmd, Some(agent_cmd), @@ -2360,19 +2357,20 @@ async fn delegate_with_context( // A WSL pane runs the agent *inside the distro* (`wsl -d -- …`), so // the Windows-host launchable check does not apply to it. Fetch the active - // pane up front so the gate below and the WSL branch further down can see - // it. See `delegate_launchable_for_source`. + // 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(); - // Legacy callers without an explicit source retain active-WSL detection. - // Profile-scoped callers provide an exact host/WSL source, which is never - // replaced when its selected agent is unavailable. - let active_wsl_distro: Option = - crate::agent_source::active_pane_wsl_distro(active.as_ref()).map(str::to_string); + // `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 { - Some(crate::agent_source::AgentSource::Host) => None, - Some(crate::agent_source::AgentSource::Wsl { distro }) => Some(distro.as_str()), - None => active_wsl_distro.as_deref(), + crate::agent_source::AgentSource::Host => None, + crate::agent_source::AgentSource::Wsl { distro } => Some(distro.as_str()), }; let wsl_agent_available = match candidate_wsl_distro { Some(distro) => { @@ -2383,34 +2381,21 @@ async fn delegate_with_context( .unwrap_or_default(); let available = wsl_delegate_agent_available(distro, &agent_exe).await; if !available { - if requested_source.is_some() { - tracing::warn!( - target: "delegate", - distro, - agent = %agent_exe, - "selected delegate agent is unavailable in its WSL distro; keeping the selected source", - ); - } else { - tracing::info!( - target: "delegate", - distro, - agent = %agent_exe, - "delegate agent not available in WSL distro — falling back to Windows host CLI", - ); - } + tracing::warn!( + target: "delegate", + distro, + agent = %agent_exe, + "selected delegate agent is unavailable in its WSL distro; keeping WSL as the source", + ); } available } None => false, }; - let launch_source = delegate_launch_source( - requested_source, - active_wsl_distro.as_deref(), - wsl_agent_available, - ); + let launch_source = requested_source; let launchable_for_target = - delegate_launchable_for_source(&launch_source, launchable, wsl_agent_available); + delegate_launchable_for_source(launch_source, launchable, wsl_agent_available); if !launchable_for_target { // Log only the executable (first token), never the full commandline: a @@ -2503,10 +2488,10 @@ async fn delegate_with_context( )?; // ── WSL delegate path ─────────────────────────────────────────────────── - // An explicit profile selection always stays in its configured distro. - // When the CLI is missing, launch the selected command there without a - // prompt so the new tab reports the real command-not-found error. Legacy - // auto-routing reaches this path only after a successful WSL probe. + // 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. // // Delivery (see `build_wsl_delegate_commandline`): the prompt rides as an // inline base64 payload decoded in-distro — base64's alphabet has no shell @@ -2521,7 +2506,7 @@ async fn delegate_with_context( // // Composability works because the two layers have disjoint special // characters: ' is special to bash, " is special to Windows. - if let crate::agent_source::AgentSource::Wsl { distro } = &launch_source { + if let crate::agent_source::AgentSource::Wsl { distro } = launch_source { let wsl_agent_cmd = crate::coordinator::build_wsl_delegate_commandline( runtime, enriched_prompt.as_deref(), From e97289e66816ecc36a406dabdefd01cea94f4534 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 16:55:56 +0800 Subject: [PATCH 6/9] Fix delegate test wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- tools/wta/src/cli_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index 012636a8b5..d29f59c27a 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -534,7 +534,7 @@ fn delegate_source_never_switches_based_on_wsl_agent_availability() { distro: "Ubuntu".to_string(), }; // An explicit WSL selection stays WSL even when its agent is missing in - // the distro — no fallback to the host. + // the distro — the selection does not fall back to the host. assert!(!delegate_launchable_for_source( &wsl, /* host_launchable */ true, From 943931a55bc83081de3bbaac02a9426295dbdeaf Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 17:14:56 +0800 Subject: [PATCH 7/9] Harden delegate diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- src/cascadia/TerminalApp/TerminalPage.cpp | 2 +- tools/wta/src/main.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index d2b64810f5..9a566323ae 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1374,7 +1374,7 @@ namespace winrt::TerminalApp::implementation // ACP agent command. if (delegateAgent.empty()) { - _agentPaneLog("ABORT: no allowed command palette agent configured"); + _agentPaneLog("ABORT: no allowed delegate agent configured"); if (AgentPolicy::IsAllowedAgentsPolicyConfigured()) { if (auto tip{ FindName(L"WindowIdToast").try_as() }) diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 070043b399..56721cc2d4 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -2151,7 +2151,12 @@ async fn run_delegate( cwd: Option<&str>, ) -> Result<()> { // Log the prompt length, not the text — the prompt is user content. - tracing::info!(prompt_chars = prompt.map(|p| p.chars().count()), agent = agent_cmd, "run_delegate started"); + // Log only the executable (first token) of agent_cmd, not the full command line with args. + 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_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)?; From 0b5d24431a33b8a61165b6d98b129933a26fc9f6 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 17:39:21 +0800 Subject: [PATCH 8/9] Keep WSL delegate cwd paths consistent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- tools/wta/src/cli_tests.rs | 79 ++++++++++++++++++++++++++++++++++++++ tools/wta/src/main.rs | 32 +++++++++++++-- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index d29f59c27a..30992a0f88 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -552,3 +552,82 @@ fn delegate_source_never_switches_based_on_wsl_agent_availability() { assert!(delegate_launchable_for_source(&AgentSource::Host, true, false)); } +// `select_wsl_delegate_cwd` picks the POSIX cwd recorded/used for an explicit +// WSL delegate launch (see PR #488 review). It must never fall back to a +// Windows/UNC `--cwd` — a WSL session's cwd has to stay a POSIX path. + +#[test] +fn select_wsl_delegate_cwd_prefers_active_pane_over_explicit_cwd() { + // Both candidates are valid POSIX paths — the active pane wins. + assert_eq!( + select_wsl_delegate_cwd(Some("/home/user/active"), Some("/home/user/explicit")), + Some("/home/user/active") + ); +} + +#[test] +fn select_wsl_delegate_cwd_falls_back_to_valid_explicit_cwd() { + // No active pane cwd — an explicit, valid POSIX `--cwd` is used. + assert_eq!( + select_wsl_delegate_cwd(None, Some("/home/user/explicit")), + Some("/home/user/explicit") + ); +} + +#[test] +fn select_wsl_delegate_cwd_rejects_windows_and_unc_paths() { + // A Windows path never wins, even when it's the only candidate — this is + // the case the review flagged: an active Windows pane under an explicit + // `--delegate-source wsl` must not leak its Windows cwd into the WSL + // session record via `wsl_cwd.or(cwd)`. + assert_eq!(select_wsl_delegate_cwd(Some("C:\\Users\\me"), None), None); + assert_eq!(select_wsl_delegate_cwd(None, Some("C:\\Users\\me")), None); + // UNC paths are likewise rejected — not absolute POSIX (`/…`). + assert_eq!(select_wsl_delegate_cwd(Some("\\\\server\\share"), None), None); + // An invalid active-pane cwd does not block falling back to a valid + // explicit `--cwd`. + assert_eq!( + select_wsl_delegate_cwd(Some("C:\\Users\\me"), Some("/home/user/explicit")), + Some("/home/user/explicit") + ); +} + +#[test] +fn select_wsl_delegate_cwd_rejects_values_containing_quotes() { + // A `"` would break the `wsl --cd ""` quoting — reject it even + // though it's otherwise an absolute POSIX-looking path. + assert_eq!( + select_wsl_delegate_cwd(Some("/home/user/\"; rm -rf /"), None), + None + ); + assert_eq!( + select_wsl_delegate_cwd(None, Some("/home/user/\"; rm -rf /")), + None + ); +} + +#[test] +fn select_wsl_delegate_cwd_trims_whitespace_before_validating() { + // Leading/trailing whitespace around an otherwise-valid POSIX path is + // trimmed away, and the trimmed value is what's returned. + assert_eq!( + select_wsl_delegate_cwd(Some(" /home/user/active "), None), + Some("/home/user/active") + ); + assert_eq!( + select_wsl_delegate_cwd(None, Some("\t/home/user/explicit\n")), + Some("/home/user/explicit") + ); + // Whitespace-only candidates are not absolute POSIX paths. + assert_eq!(select_wsl_delegate_cwd(Some(" "), None), None); +} + +#[test] +fn select_wsl_delegate_cwd_returns_none_when_neither_candidate_is_valid() { + assert_eq!(select_wsl_delegate_cwd(None, None), None); + assert_eq!( + select_wsl_delegate_cwd(Some("relative/path"), Some("also/relative")), + None + ); +} + diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 56721cc2d4..04a16414a7 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -2321,6 +2321,30 @@ fn cap_delegate_context(context: &str, max_bytes: usize) -> String { format!("{marker}{}", &context[start..]) } +/// Selects the POSIX cwd to record/use for an explicit WSL delegate launch. +/// +/// A WSL session's cwd must be a POSIX path (`/…`) — an unbounded fallback 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 (see PR #488 review). 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. +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. WT's GetActivePane already resolves the agent pane to @@ -2520,11 +2544,11 @@ async fn delegate_with_context( 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 + let active_pane_cwd = active .as_ref() .and_then(|pane| pane.get("cwd")) - .and_then(|v| v.as_str()) - .filter(|s| s.starts_with('/') && !s.contains('"')); + .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}") @@ -2574,7 +2598,7 @@ async fn delegate_with_context( sid, pane, &runtime.id, - wsl_cwd.or(cwd), + wsl_cwd, Some(distro), ) .await; From ea025481bfb194aefdd289932f795ab04daa0c4a Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 24 Jul 2026 18:01:20 +0800 Subject: [PATCH 9/9] Validate delegate distro flag presence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0a2b53-dbbe-4b64-b09b-6c5b6d5e084a --- tools/wta/src/cli_tests.rs | 18 ++++++++++++++++++ tools/wta/src/main.rs | 20 ++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index 30992a0f88..d4f83d9461 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -511,6 +511,24 @@ fn delegate_source_args_require_a_valid_exact_target() { assert!(parse_delegate_source(Some("host"), Some("Ubuntu")).is_err()); assert!(parse_delegate_source(None, Some("Ubuntu")).is_err()); assert!(parse_delegate_source(Some("remote"), None).is_err()); + + // Any presence of --delegate-wsl-distro (even empty/whitespace-only) is + // rejected for an omitted source or an explicit --delegate-source host — + // the caller passed the flag, so a silently-ignored empty value would be + // misleading. + assert!(parse_delegate_source(None, Some("")).is_err()); + assert!(parse_delegate_source(None, Some(" ")).is_err()); + assert!(parse_delegate_source(Some("host"), Some("")).is_err()); + assert!(parse_delegate_source(Some("host"), Some(" ")).is_err()); + + // --delegate-source wsl still trims and requires a non-empty distro. + assert!(parse_delegate_source(Some("wsl"), Some(" ")).is_err()); + assert_eq!( + parse_delegate_source(Some("wsl"), Some(" Ubuntu ")).unwrap(), + AgentSource::Wsl { + distro: "Ubuntu".to_string() + } + ); } #[test] diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 04a16414a7..26554d73eb 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -2235,14 +2235,14 @@ fn parse_delegate_source( match source.map(str::trim) { None => { anyhow::ensure!( - wsl_distro.map(str::trim).filter(|distro| !distro.is_empty()).is_none(), + 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.map(str::trim).filter(|distro| !distro.is_empty()).is_none(), + wsl_distro.is_none(), "--delegate-wsl-distro is invalid with --delegate-source host" ); Ok(AgentSource::Host) @@ -2323,14 +2323,14 @@ fn cap_delegate_context(context: &str, max_bytes: usize) -> String { /// Selects the POSIX cwd to record/use for an explicit WSL delegate launch. /// -/// A WSL session's cwd must be a POSIX path (`/…`) — an unbounded fallback 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 (see PR #488 review). 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. +/// 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 (see PR #488 review). 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. fn select_wsl_delegate_cwd<'a>( active_pane_cwd: Option<&'a str>, explicit_cwd: Option<&'a str>,