diff --git a/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp b/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp index f91c43543..da3926dbf 100644 --- a/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp @@ -153,6 +153,8 @@ namespace winrt::TerminalApp::implementation if (const auto termControl = effectivePane->GetTerminalControl()) { result.Cwd = termControl.WorkingDirectory(); + result.Shell = termControl.ShellName(); + result.ShellVersion = termControl.ShellVersion(); } result.Pid = _getPidFromPane(effectivePane); @@ -256,6 +258,8 @@ namespace winrt::TerminalApp::implementation info.Rows = termControl.ViewHeight(); info.Columns = 0; info.Cwd = termControl.WorkingDirectory(); + info.Shell = termControl.ShellName(); + info.ShellVersion = termControl.ShellVersion(); } } diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index db973d631..057c6b73c 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -1522,6 +1522,18 @@ namespace winrt::Microsoft::Terminal::Control::implementation return hstring{ _terminal->GetWorkingDirectory() }; } + hstring ControlCore::ShellName() const + { + const auto lock = _terminal->LockForReading(); + return hstring{ _terminal->GetShellName() }; + } + + hstring ControlCore::ShellVersion() const + { + const auto lock = _terminal->LockForReading(); + return hstring{ _terminal->GetShellVersion() }; + } + bool ControlCore::BracketedPasteEnabled() const noexcept { const auto lock = _terminal->LockForReading(); diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index bbc03a50e..811728d05 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -167,6 +167,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation hstring Title(); Windows::Foundation::IReference TabColor() noexcept; hstring WorkingDirectory() const; + hstring ShellName() const; + hstring ShellVersion() const; TerminalConnection::ConnectionState ConnectionState() const; diff --git a/src/cascadia/TerminalControl/ICoreState.idl b/src/cascadia/TerminalControl/ICoreState.idl index f03d0391c..c20bfc667 100644 --- a/src/cascadia/TerminalControl/ICoreState.idl +++ b/src/cascadia/TerminalControl/ICoreState.idl @@ -36,6 +36,12 @@ namespace Microsoft.Terminal.Control String WorkingDirectory { get; }; + // Shell identity reported by shell integration via OSC 9001;ShellType. + // Empty when the shell has not reported (no shell integration, or a + // shell that does not emit the sequence). + String ShellName { get; }; + String ShellVersion { get; }; + Windows.Foundation.IReference TabColor { get; }; Int32 ScrollOffset { get; }; diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 95d0e11b7..0840d23ba 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -2550,6 +2550,16 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _core.WorkingDirectory(); } + hstring TermControl::ShellName() const + { + return _core.ShellName(); + } + + hstring TermControl::ShellVersion() const + { + return _core.ShellVersion(); + } + bool TermControl::BracketedPasteEnabled() const noexcept { return _core.BracketedPasteEnabled(); diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index f88a2aa83..f08fc6c90 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -92,6 +92,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation hstring Title(); Windows::Foundation::IReference TabColor() noexcept; hstring WorkingDirectory() const; + hstring ShellName() const; + hstring ShellVersion() const; TerminalConnection::ConnectionState ConnectionState() const; diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index b63cf1217..a76e79218 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -280,6 +280,16 @@ std::wstring_view Terminal::GetWorkingDirectory() noexcept return _workingDirectory; } +std::wstring_view Terminal::GetShellName() const noexcept +{ + return _shellName; +} + +std::wstring_view Terminal::GetShellVersion() const noexcept +{ + return _shellVersion; +} + // Method Description: // - Resize the terminal as the result of some user interaction. // Arguments: diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index b05f3987c..d0a974d80 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -99,6 +99,8 @@ class Microsoft::Terminal::Core::Terminal final : void SetOptionalFeatures(winrt::Microsoft::Terminal::Core::ICoreSettings settings); bool IsXtermBracketedPasteModeEnabled() const noexcept; std::wstring_view GetWorkingDirectory() noexcept; + std::wstring_view GetShellName() const noexcept; + std::wstring_view GetShellVersion() const noexcept; til::point GetViewportRelativeCursorPosition() const noexcept; @@ -152,6 +154,7 @@ class Microsoft::Terminal::Core::Terminal final : void CopyToClipboard(wil::zwstring_view content) override; void SetTaskbarProgress(const ::Microsoft::Console::VirtualTerminal::DispatchTypes::TaskbarState state, const size_t progress) override; void SetWorkingDirectory(std::wstring_view uri) override; + void SetShellType(std::wstring_view shellName, std::wstring_view shellVersion) override; void PlayMidiNote(const int noteNumber, const int velocity, const std::chrono::microseconds duration) override; void ShowWindow(bool showOrHide) override; void UseAlternateScreenBuffer(const TextAttribute& attrs) override; @@ -382,6 +385,8 @@ class Microsoft::Terminal::Core::Terminal final : std::wstring _answerbackMessage; std::wstring _workingDirectory; + std::wstring _shellName; + std::wstring _shellVersion; bool _highContrastMode = false; // This default fake font value is only used to check if the font is a raster font. diff --git a/src/cascadia/TerminalCore/TerminalApi.cpp b/src/cascadia/TerminalCore/TerminalApi.cpp index 14c384881..8562ff90c 100644 --- a/src/cascadia/TerminalCore/TerminalApi.cpp +++ b/src/cascadia/TerminalCore/TerminalApi.cpp @@ -7,6 +7,8 @@ #include "../src/inc/unicode.hpp" +#include + using namespace Microsoft::Terminal::Core; using namespace Microsoft::Console::Render; using namespace Microsoft::Console::Types; @@ -234,6 +236,29 @@ void Terminal::SetWorkingDirectory(std::wstring_view uri) _workingDirectory = uri; } +void Terminal::SetShellType(std::wstring_view shellName, std::wstring_view shellVersion) +{ + _assertLocked(); + + // Telemetry is logged once per process. `logged` is function-static + // (shared across all Terminal instances, which hold *different* locks), + // so the `_assertLocked()` above does NOT serialize it — use an atomic + // exchange to make the one-shot gate race-free. + static std::atomic logged{ false }; + if (!logged.exchange(true)) + { + TraceLoggingWrite( + g_hCTerminalCoreProvider, + "ShellIntegrationShellTypeSet", + TraceLoggingDescription("The shell reported its identity via OSC 9001;ShellType"), + TraceLoggingKeyword(MICROSOFT_KEYWORD_MEASURES), + TelemetryPrivacyDataTag(PDT_ProductAndServiceUsage)); + } + + _shellName = shellName; + _shellVersion = shellVersion; +} + void Terminal::PlayMidiNote(const int noteNumber, const int velocity, const std::chrono::microseconds duration) { _pfnPlayMidiNote(noteNumber, velocity, duration); diff --git a/src/cascadia/TerminalProtocol/TerminalProtocol.idl b/src/cascadia/TerminalProtocol/TerminalProtocol.idl index 95f9a09ef..089f71b1f 100644 --- a/src/cascadia/TerminalProtocol/TerminalProtocol.idl +++ b/src/cascadia/TerminalProtocol/TerminalProtocol.idl @@ -38,6 +38,11 @@ namespace Microsoft.Terminal.Protocol // terminal control's working-directory tracking (OSC 9;9 / shell // integration). Empty when not known. String Cwd; + // Shell identity reported by shell integration via OSC 9001;ShellType + // (e.g. "pwsh", "powershell", "bash", "wsl:Ubuntu"). Empty when the + // shell has not reported its identity. + String Shell; + String ShellVersion; }; struct PaneOutput diff --git a/src/cascadia/UnitTests_TerminalCore/ShellIntegrationTests.cpp b/src/cascadia/UnitTests_TerminalCore/ShellIntegrationTests.cpp index e598e9c2a..3ed56ea0a 100644 --- a/src/cascadia/UnitTests_TerminalCore/ShellIntegrationTests.cpp +++ b/src/cascadia/UnitTests_TerminalCore/ShellIntegrationTests.cpp @@ -60,6 +60,7 @@ class TerminalCoreUnitTests::ShellIntegrationTests final TEST_METHOD(Install_IdempotentWhenAlreadyInstalled); TEST_METHOD(Install_ReinstallsWhenScriptMissingButBlockMatches); TEST_METHOD(Install_RewritesLegacyDotSourceLineInPlace); + TEST_METHOD(Install_UpgradesWhenBlockReferencesOlderScriptVersion); TEST_METHOD(Install_OverwritesOrphanOpenMarker); TEST_METHOD(Install_CreatesBackupForNonEmptyProfile); TEST_METHOD(Install_DoesNotCreateBackupForEmptyProfile); @@ -119,6 +120,7 @@ class TerminalCoreUnitTests::ShellIntegrationTests final TEST_METHOD(Bash_Install_IsLfOnly); TEST_METHOD(Bash_Install_IdempotentWhenAlreadyInstalled); TEST_METHOD(Bash_Install_ReinstallsWhenScriptMissingButBlockMatches); + TEST_METHOD(Bash_Install_UpgradesWhenBlockReferencesOlderScriptVersion); TEST_METHOD(Bash_Install_OverwritesOrphanOpenMarker); TEST_METHOD(Bash_Install_CreatesBackupForNonEmptyProfile); TEST_METHOD(Bash_Install_DoesNotCreateBackupForEmptyProfile); @@ -546,6 +548,43 @@ void ShellIntegrationTests::Install_RewritesLegacyDotSourceLineInPlace() VERIFY_IS_TRUE(blockPos < tailPos, L"In-place rewrite — block stays where legacy line was"); } +void ShellIntegrationTests::Install_UpgradesWhenBlockReferencesOlderScriptVersion() +{ + // Regression: bumping the script version (e.g. v1 -> v2 when OSC + // 9001;ShellType emission was added) must actually reach existing + // users. Their $PROFILE already has a well-formed managed block, but it + // references the OLDER versioned script filename and the stale old + // script sits on disk. The block-match early-out must NOT fire (the + // block no longer equals the desired, current-version block), so the + // current script is written and the block is rewritten to point at it. + const auto profile = _ProfilePath(); + const auto currentName = til::u16u8(ShellIntegrationScriptFileName()); + + // Simulate a prior install: take the current block and point it at an + // older script version. v0 is always older than any shipped vN, so this + // stays valid across future bumps. + const std::string oldName = "shell-integration_v0.ps1"; + auto oldBlock = BuildShellIntegrationBlock(L"PowerShell", "\n"); + const auto namePos = oldBlock.find(currentName); + VERIFY_ARE_NOT_EQUAL(std::string::npos, namePos, L"Block must embed the current script filename"); + oldBlock.replace(namePos, currentName.size(), oldName); + _WriteFile(profile, oldBlock + "\n"); + // Stale old script on disk, without the ShellType emission. + _WriteFile(profile.parent_path() / L"shell-integration_v0.ps1", "# stale old script, no ShellType\n"); + + const auto r = Install(profile.wstring()); + VERIFY_IS_TRUE(r.success); + VERIFY_IS_FALSE(r.alreadyInstalled, L"Block referenced an older script version → must upgrade, not no-op"); + + const auto contents = _ReadFile(profile); + VERIFY_IS_TRUE(_Contains(contents, currentName), L"Block must be rewritten to the current script version"); + VERIFY_IS_FALSE(_Contains(contents, oldName), L"Old version reference must be replaced"); + + const auto scriptPath = profile.parent_path() / ShellIntegrationScriptFileName(); + VERIFY_IS_TRUE(std::filesystem::exists(scriptPath), L"Current-version script must be written on upgrade"); + VERIFY_IS_TRUE(_Contains(_ReadFile(scriptPath), "9001"), L"Upgraded script must emit OSC 9001;ShellType"); +} + void ShellIntegrationTests::Install_OverwritesOrphanOpenMarker() { const auto profile = _ProfilePath(); @@ -1210,6 +1249,38 @@ void ShellIntegrationTests::Bash_Install_ReinstallsWhenScriptMissingButBlockMatc VERIFY_IS_TRUE(std::filesystem::exists(scriptPath)); } +void ShellIntegrationTests::Bash_Install_UpgradesWhenBlockReferencesOlderScriptVersion() +{ + // Bash/WSL counterpart of the PowerShell upgrade regression: an existing + // ~/.bashrc managed block that references an OLDER versioned script must + // be rewritten to the current version (so the OSC 9001;ShellType emission + // added in v2 actually reaches users who already had v1 installed). + const auto profile = _BashProfilePath(); + const auto scriptDir = _BashScriptDir(); + const auto currentName = til::u16u8(ShellIntegrationBashScriptFileName()); + + const std::string oldName = "shell-integration_v0.sh"; + auto oldBlock = BuildShellIntegrationBashBlock(); + const auto namePos = oldBlock.find(currentName); + VERIFY_ARE_NOT_EQUAL(std::string::npos, namePos, L"Block must embed the current script filename"); + oldBlock.replace(namePos, currentName.size(), oldName); + _WriteFile(profile, oldBlock + "\n"); + // Stale old script on disk, without the ShellType emission. + _WriteFile(scriptDir / L"shell-integration_v0.sh", "# stale old script, no ShellType\n"); + + const auto r = InstallBash(profile.wstring(), scriptDir.wstring()); + VERIFY_IS_TRUE(r.success); + VERIFY_IS_FALSE(r.alreadyInstalled, L"Block referenced an older script version → must upgrade, not no-op"); + + const auto contents = _ReadFile(profile); + VERIFY_IS_TRUE(_Contains(contents, currentName), L"Block must be rewritten to the current script version"); + VERIFY_IS_FALSE(_Contains(contents, oldName), L"Old version reference must be replaced"); + + const auto scriptPath = scriptDir / ShellIntegrationBashScriptFileName(); + VERIFY_IS_TRUE(std::filesystem::exists(scriptPath), L"Current-version script must be written on upgrade"); + VERIFY_IS_TRUE(_Contains(_ReadFile(scriptPath), "9001"), L"Upgraded script must emit OSC 9001;ShellType"); +} + void ShellIntegrationTests::Bash_Install_OverwritesOrphanOpenMarker() { const auto profile = _BashProfilePath(); diff --git a/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp b/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp index 51e53f173..0e5f67eac 100644 --- a/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp +++ b/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp @@ -39,6 +39,7 @@ namespace TerminalCoreUnitTests TEST_METHOD(SetTaskbarProgress); TEST_METHOD(SetWorkingDirectory); + TEST_METHOD(SetShellType); }; }; @@ -391,3 +392,44 @@ void TerminalCoreUnitTests::TerminalApiTest::SetWorkingDirectory() stateMachine.ProcessString(L"\x1b]9;9;D:\\中文\x1b\\"); VERIFY_ARE_EQUAL(term.GetWorkingDirectory(), L"D:\\中文"); } + +void TerminalCoreUnitTests::TerminalApiTest::SetShellType() +{ + Terminal term{ Terminal::TestDummyMarker{} }; + DummyRenderer renderer{ &term }; + term.Create({ 100, 100 }, 0, renderer); + + auto& stateMachine = *(term._stateMachine); + + // The shell reports its identity via OSC 9001;ShellType;;. + // Initially nothing has been reported. + VERIFY_IS_TRUE(term.GetShellName().empty()); + VERIFY_IS_TRUE(term.GetShellVersion().empty()); + + // Name + version both present. + stateMachine.ProcessString(L"\x1b]9001;ShellType;pwsh;7.4.1\x1b\\"); + VERIFY_ARE_EQUAL(term.GetShellName(), L"pwsh"); + VERIFY_ARE_EQUAL(term.GetShellVersion(), L"7.4.1"); + + // A later prompt from a different shell (e.g. after `wsl` exits) overwrites + // the previously reported identity — last writer wins. + stateMachine.ProcessString(L"\x1b]9001;ShellType;wsl:Ubuntu;5.15.0\x1b\\"); + VERIFY_ARE_EQUAL(term.GetShellName(), L"wsl:Ubuntu"); + VERIFY_ARE_EQUAL(term.GetShellVersion(), L"5.15.0"); + + // Version is optional — name only still updates the name and clears version. + stateMachine.ProcessString(L"\x1b]9001;ShellType;bash\x1b\\"); + VERIFY_ARE_EQUAL(term.GetShellName(), L"bash"); + VERIFY_IS_TRUE(term.GetShellVersion().empty()); + + // A ShellType action with no name field is tolerated and clears both. + stateMachine.ProcessString(L"\x1b]9001;ShellType\x1b\\"); + VERIFY_IS_TRUE(term.GetShellName().empty()); + VERIFY_IS_TRUE(term.GetShellVersion().empty()); + + // An unrelated WT sub-action must not touch the stored shell identity. + stateMachine.ProcessString(L"\x1b]9001;ShellType;powershell;5.1\x1b\\"); + stateMachine.ProcessString(L"\x1b]9001;CmdNotFound;somecmd\x1b\\"); + VERIFY_ARE_EQUAL(term.GetShellName(), L"powershell"); + VERIFY_ARE_EQUAL(term.GetShellVersion(), L"5.1"); +} diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp index 801898b7c..8639da4bc 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp @@ -248,6 +248,8 @@ static Json::Value _toJson(const Protocol::PaneInfo& p) v["size"]["rows"] = p.Rows; v["size"]["columns"] = p.Columns; v["cwd"] = winrt::to_string(p.Cwd); + v["shell"] = winrt::to_string(p.Shell); + v["shell_version"] = winrt::to_string(p.ShellVersion); return v; } diff --git a/src/cascadia/inc/BashShellIntegration.h b/src/cascadia/inc/BashShellIntegration.h index 78ee97cfc..b76df7139 100644 --- a/src/cascadia/inc/BashShellIntegration.h +++ b/src/cascadia/inc/BashShellIntegration.h @@ -34,7 +34,13 @@ namespace Microsoft::Terminal::ShellIntegration::Bash { - inline constexpr int kVersion = 1; + // v2: added OSC 9001;ShellType emission (bash/WSL self-reports identity + // each prompt). Bumped from v1 so existing users — whose ~/.bashrc + // already references the v1 script byte-for-byte — get the new script + // rewritten in; without the bump the orchestrator's block-match early- + // out would leave the stale v1 script (no ShellType) in place. WSL + // inherits this version via WslBashFlavor. + inline constexpr int kVersion = 2; inline std::wstring ScriptFileName() { @@ -143,6 +149,15 @@ __it_shellinteg_prompt() { # CWD reporting for those directories. The unquoted form parses # cleanly regardless of path contents. printf '\033]133;D;%s\007\033]133;A\007\033]9;9;%s\007' "$__ec" "${PWD:-}" + # OSC 9001;ShellType — report shell identity each prompt so the terminal + # always knows which shell owns the pane, even after a nested shell exits. + # Under WSL, $WSL_DISTRO_NAME is set so we report "wsl:"; plain + # (Git) bash reports "bash". + if [ -n "${WSL_DISTRO_NAME:-}" ]; then + printf '\033]9001;ShellType;wsl:%s;%s\007' "$WSL_DISTRO_NAME" "${BASH_VERSION:-}" + else + printf '\033]9001;ShellType;bash;%s\007' "${BASH_VERSION:-}" + fi if [ -n "$__IT_SHELLINTEG_USER_PC" ]; then # Restore $? for the user's PROMPT_COMMAND so hooks like # `local ec=$?` at its top still see the real exit code diff --git a/src/cascadia/inc/PowerShellShellIntegration.h b/src/cascadia/inc/PowerShellShellIntegration.h index fae727f00..13fb56874 100644 --- a/src/cascadia/inc/PowerShellShellIntegration.h +++ b/src/cascadia/inc/PowerShellShellIntegration.h @@ -346,8 +346,14 @@ namespace Microsoft::Terminal::ShellIntegration::Powershell // `shell-integration*.ps1` reference in $PROFILE and rewrite it to // point at the current version. Older script files left on disk are // inert (never referenced). To roll out a new version, bump this. + // + // v2: added OSC 9001;ShellType emission (shell self-reports identity + // each prompt). Bumped from v1 so existing users — whose $PROFILE + // already references the v1 script byte-for-byte — get the new script + // rewritten in; without the bump the orchestrator's block-match early- + // out would leave the stale v1 script (no ShellType) in place. // ─────────────────────────────────────────────────────────────────── - inline constexpr int kVersion = 1; + inline constexpr int kVersion = 2; inline std::wstring ScriptFileName() { @@ -437,6 +443,14 @@ if (-not $Global:__ShellInteg_Installed) { # ── Report current working directory (OSC 9;9) ── $prefix += "${E}]9;9;`"${loc}`"${B}" + # ── Report shell identity (OSC 9001;ShellType) ── + # Emitted every prompt so the terminal always knows which shell owns + # the pane, even after a nested shell (e.g. wsl) exits and PowerShell + # repaints its prompt. PSEdition 'Core' is pwsh 7+, 'Desktop' is + # Windows PowerShell 5.1. + $shellName = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' } + $prefix += "${E}]9001;ShellType;${shellName};$($PSVersionTable.PSVersion)${B}" + # ── Prompt ended, command input starts (OSC 133;B) ── $suffix = "${E}]133;B${B}" diff --git a/src/host/outputStream.cpp b/src/host/outputStream.cpp index c26e20a74..f389a3195 100644 --- a/src/host/outputStream.cpp +++ b/src/host/outputStream.cpp @@ -335,6 +335,18 @@ void ConhostInternalGetSet::SetWorkingDirectory(const std::wstring_view /*uri*/) { } +// Routine Description: +// - Records the shell identity reported via OSC 9001;ShellType. This is a +// Windows Terminal feature with no meaning in conhost, so it is a no-op here. +// Arguments: +// - shellName - the reported shell name (unused in conhost). +// - shellVersion - the reported shell version (unused in conhost). +// Return Value: +// - +void ConhostInternalGetSet::SetShellType(const std::wstring_view /*shellName*/, const std::wstring_view /*shellVersion*/) +{ +} + // Routine Description: // - Plays a single MIDI note, blocking for the duration. // Arguments: diff --git a/src/host/outputStream.hpp b/src/host/outputStream.hpp index d61482538..e4bfd502a 100644 --- a/src/host/outputStream.hpp +++ b/src/host/outputStream.hpp @@ -63,6 +63,7 @@ class ConhostInternalGetSet final : public Microsoft::Console::VirtualTerminal:: void CopyToClipboard(const wil::zwstring_view content) override; void SetTaskbarProgress(const ::Microsoft::Console::VirtualTerminal::DispatchTypes::TaskbarState state, const size_t progress) override; void SetWorkingDirectory(const std::wstring_view uri) override; + void SetShellType(const std::wstring_view shellName, const std::wstring_view shellVersion) override; void PlayMidiNote(const int noteNumber, const int velocity, const std::chrono::microseconds duration) override; bool IsVtInputEnabled() const override; diff --git a/src/terminal/adapter/ITerminalApi.hpp b/src/terminal/adapter/ITerminalApi.hpp index a6c12ea5e..ab07d788f 100644 --- a/src/terminal/adapter/ITerminalApi.hpp +++ b/src/terminal/adapter/ITerminalApi.hpp @@ -82,6 +82,7 @@ namespace Microsoft::Console::VirtualTerminal virtual void CopyToClipboard(const wil::zwstring_view content) = 0; virtual void SetTaskbarProgress(const DispatchTypes::TaskbarState state, const size_t progress) = 0; virtual void SetWorkingDirectory(const std::wstring_view uri) = 0; + virtual void SetShellType(const std::wstring_view shellName, const std::wstring_view shellVersion) = 0; virtual void PlayMidiNote(const int noteNumber, const int velocity, const std::chrono::microseconds duration) = 0; virtual bool ResizeWindow(const til::CoordType width, const til::CoordType height) = 0; diff --git a/src/terminal/adapter/adaptDispatch.cpp b/src/terminal/adapter/adaptDispatch.cpp index d0b9579d7..c187db0f1 100644 --- a/src/terminal/adapter/adaptDispatch.cpp +++ b/src/terminal/adapter/adaptDispatch.cpp @@ -3844,6 +3844,8 @@ void AdaptDispatch::DoVsCodeAction(const std::wstring_view string) // to the terminal. The command is then shared with WinGet to see if it can // find a package that provides that command, which is then displayed to the // user. +// * ShellType: The shell reports its own identity (name + version) so the +// terminal knows which shell currently owns the pane. // - Not actually used in conhost // Arguments: // - string: contains the parameters that define which action we do @@ -3870,32 +3872,19 @@ void AdaptDispatch::DoWTAction(const std::wstring_view string) _api.SearchMissingCommand(missingCmd); } } - else if (action == L"WtaReq") + else if (action == L"ShellType") { - // WTA (Windows Terminal Agent) protocol request via VT escape sequence. - // Format: \x1b]9001;WtaReq;{json}\x07 - // Supports: {"method":"discover"} → returns pipe name + token for protocol access. - // The response is injected back as \x1b]9001;WtaRes;{json}\x1b\\ via stdin. - if (parts.size() >= 2) - { - const auto payload = til::at(parts, 1); - - // Check if this is a "discover" request - if (payload.find(L"discover") != std::wstring_view::npos) - { - // Compute pipe name from current PID — matches what WindowEmperor creates. - const auto pid = GetCurrentProcessId(); - const auto pipeName = fmt::format(FMT_COMPILE(L"\\\\\\\\.\\\\pipe\\\\WindowsTerminal-{}"), pid); - const auto response = fmt::format(FMT_COMPILE(L"9001;WtaRes;{{\"status\":\"ok\",\"pipe\":\"{}\",\"token\":\"\"}}"), pipeName); - _ReturnOscResponse(response); - } - else - { - // Default ack for other methods (e.g. "identify") - const auto response = fmt::format(FMT_COMPILE(L"9001;WtaRes;{{\"status\":\"ok\",\"vt_supported\":true}}")); - _ReturnOscResponse(response); - } - } + // The shell reports its own identity once per prompt so the terminal + // always knows which shell currently owns the pane (including after a + // nested shell like `wsl` exits). + // The structure of the message is as follows: + // `e]9001; + // 0: ShellType; + // 1: (e.g. pwsh, powershell, bash, wsl:Ubuntu) + // 2: (optional) + const std::wstring_view shellName = parts.size() >= 2 ? til::at(parts, 1) : std::wstring_view{}; + const std::wstring_view shellVersion = parts.size() >= 3 ? til::at(parts, 2) : std::wstring_view{}; + _api.SetShellType(shellName, shellVersion); } } diff --git a/src/terminal/adapter/ut_adapter/adapterTest.cpp b/src/terminal/adapter/ut_adapter/adapterTest.cpp index 3af5db159..697399105 100644 --- a/src/terminal/adapter/ut_adapter/adapterTest.cpp +++ b/src/terminal/adapter/ut_adapter/adapterTest.cpp @@ -206,6 +206,11 @@ class TestGetSet final : public ITerminalApi Log::Comment(L"SetWorkingDirectory MOCK called..."); } + void SetShellType(const std::wstring_view /*shellName*/, const std::wstring_view /*shellVersion*/) override + { + Log::Comment(L"SetShellType MOCK called..."); + } + void PlayMidiNote(const int /*noteNumber*/, const int /*velocity*/, const std::chrono::microseconds /*duration*/) override { Log::Comment(L"PlayMidiNote MOCK called..."); diff --git a/test/e2e/ItE2E/ItE2E.psm1 b/test/e2e/ItE2E/ItE2E.psm1 index 071c3fcd0..c2048d9f1 100644 --- a/test/e2e/ItE2E/ItE2E.psm1 +++ b/test/e2e/ItE2E/ItE2E.psm1 @@ -14,7 +14,7 @@ foreach ($scope in @('Private', 'Public')) { # Export every function defined by the Public files (and the few private ones tests use). $publicFns = @( # Harness - 'Resolve-ItApp', 'Resolve-WtComClsid', 'Start-Terminal', 'Start-TerminalClean', 'Stop-Terminal', + 'Resolve-ItApp', 'Resolve-WtComClsid', 'Get-ItTestPackage', 'Start-Terminal', 'Start-TerminalClean', 'Stop-Terminal', 'Reset-TerminalState', 'Backup-WtConfig', 'Restore-WtConfig', 'Get-WtProcessesForApp', 'Stop-AppInstances', 'Start-TerminalFre', 'Get-DescendantWtaIds', # Core (useful in tests) 'Wait-Until', 'Test-Until', 'Invoke-Native', 'Write-ItLog', 'ConvertFrom-JsonSafe', @@ -31,7 +31,7 @@ $publicFns = @( 'Test-WtExecutionPolicyControllable', 'Test-WtPwshBlocksShellIntegration', # Ui 'Get-UiTree', 'Find-UiElement', 'Invoke-UiElement', 'Invoke-UiClick', 'Set-UiValue', 'Get-UiValue', - 'Wait-UiElement', 'Test-UiElementExists', 'Save-UiScreenshot', 'Get-WtWindowHwnds', + 'Wait-UiElement', 'Test-UiElementExists', 'Save-UiScreenshot', 'Get-WtWindowHwnds', 'Test-WinAppAvailable', # Observe 'Get-ItLogDir', 'Initialize-LogOffsets', 'Get-ItLogText', 'Start-WtEventListener', 'Get-WtEvents', 'Wait-WtEvent', 'Stop-WtEventListener', 'Get-ContextBundle', 'ConvertTo-ContextText', diff --git a/test/e2e/ItE2E/Public/Harness.ps1 b/test/e2e/ItE2E/Public/Harness.ps1 index d1bc2f12f..f1475ab0a 100644 --- a/test/e2e/ItE2E/Public/Harness.ps1 +++ b/test/e2e/ItE2E/Public/Harness.ps1 @@ -88,6 +88,21 @@ function Stop-AppInstances { Start-Sleep -Milliseconds 500 } +function Get-ItTestPackage { + <# + .SYNOPSIS + Resolve which package selector the feature/self-test suites should launch. + Honors the ITE2E_PACKAGE env var (Auto|Store|Dev|); defaults + to 'Auto', which prefers a fully-resolvable Store install and falls back to Dev. + This is the single knob that lets the suites run against a dev-only machine + (where only the sideload package is installed) without editing each Describe. + #> + [CmdletBinding()] + param() + if ($env:ITE2E_PACKAGE) { return $env:ITE2E_PACKAGE } + return 'Auto' +} + function Start-Terminal { <# .SYNOPSIS @@ -256,7 +271,7 @@ function Start-TerminalFre { Backs up config for restore on Stop-Terminal. #> [CmdletBinding()] - param([string]$Package = 'Store', [int]$TimeoutSec = 60) + param([string]$Package = (Get-ItTestPackage), [int]$TimeoutSec = 60) return (Start-Terminal -Package $Package -ColdStart -ShowFre -Backup $true -TimeoutSec $TimeoutSec) } diff --git a/test/e2e/ItE2E/Public/Ui.ps1 b/test/e2e/ItE2E/Public/Ui.ps1 index d81b67190..0a1199048 100644 --- a/test/e2e/ItE2E/Public/Ui.ps1 +++ b/test/e2e/ItE2E/Public/Ui.ps1 @@ -8,6 +8,23 @@ function Get-WinAppPath { $script:ItWinAppPath = $c; $c } +function Test-WinAppAvailable { + <# + .SYNOPSIS + Non-throwing probe for the winapp (Windows App CLI) UI-automation tool. + .DESCRIPTION + Returns $true when winapp is on PATH (or already resolved), $false otherwise — unlike + Get-WinAppPath, which throws. Use this in a Describe readiness gate so UI-dependent + suites SKIP cleanly when winapp is missing instead of blowing up in BeforeAll with a + raw "winapp not found" exception. Install winapp via test/e2e/bootstrap.ps1. + #> + [CmdletBinding()] + [OutputType([bool])] + param() + if ($script:ItWinAppPath -and (Test-Path $script:ItWinAppPath)) { return $true } + [bool](Get-Command winapp -ErrorAction SilentlyContinue) +} + function Get-UiTarget { param([Parameter(Mandatory)]$App) if ($App.Hwnd) { return @('-w', [string]$App.Hwnd) } diff --git a/test/e2e/README.md b/test/e2e/README.md index 37998feae..16353b1ec 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -161,7 +161,11 @@ restores the backup. > **Picking the build**: pass `-Package Dev` / `-Package Store` (default `Auto`) — see > [Choosing the build](#choosing-the-build-dev-vs-store). Launch is package-specific -> (AUMID), so both builds can be installed and targeted independently. +> (AUMID), so both builds can be installed and targeted independently. The feature/self +> -test suites don't hardcode a build — they call `Start-Terminal -Package (Get-ItTestPackage)`, +> which honors the `ITE2E_PACKAGE` env var (`Auto`|`Store`|`Dev`|``) +> and defaults to `Auto`. So on a dev-only machine the suites resolve to the sideload +> build automatically; set `$env:ITE2E_PACKAGE='Store'` to pin them to the store build. ## How it works (key facts) diff --git a/test/e2e/selftests/ItE2E.Agent.Tests.ps1 b/test/e2e/selftests/ItE2E.Agent.Tests.ps1 index 5eeba05b8..208700817 100644 --- a/test/e2e/selftests/ItE2E.Agent.Tests.ps1 +++ b/test/e2e/selftests/ItE2E.Agent.Tests.ps1 @@ -12,7 +12,7 @@ Describe 'Agent pane + autofix' -Tag 'Agent' -Skip:(-not ($script:HasPackage -an BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } diff --git a/test/e2e/selftests/ItE2E.Live.Tests.ps1 b/test/e2e/selftests/ItE2E.Live.Tests.ps1 index 2e514a593..d5d78a88b 100644 --- a/test/e2e/selftests/ItE2E.Live.Tests.ps1 +++ b/test/e2e/selftests/ItE2E.Live.Tests.ps1 @@ -11,7 +11,7 @@ Describe 'ItE2E live primitives' -Tag 'Live' -Skip:(-not $script:HasPackage) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } diff --git a/test/e2e/tests/Feature.AgentChat.Tests.ps1 b/test/e2e/tests/Feature.AgentChat.Tests.ps1 index ff071d7a6..5e64722eb 100644 --- a/test/e2e/tests/Feature.AgentChat.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentChat.Tests.ps1 @@ -5,13 +5,13 @@ # Invoke-Pester test/e2e/tests -Tag Feature BeforeDiscovery { - $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) + $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue)) } Describe 'Feature: agent pane chat' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot' } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } diff --git a/test/e2e/tests/Feature.AgentPaneInteraction.Tests.ps1 b/test/e2e/tests/Feature.AgentPaneInteraction.Tests.ps1 index dd4c3ff3d..cb6af0a89 100644 --- a/test/e2e/tests/Feature.AgentPaneInteraction.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentPaneInteraction.Tests.ps1 @@ -3,12 +3,12 @@ # Agent pane slash commands (2) + Built-in agent chat (copilot) (2). # Invoke-Pester test/e2e/tests -Tag Feature -BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) } +BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue)) } Describe 'Feature: agent pane open/hide/focus + input + slash + chat' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot' } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } diff --git a/test/e2e/tests/Feature.AgentPopup.Tests.ps1 b/test/e2e/tests/Feature.AgentPopup.Tests.ps1 index 26b85c233..6b527d0b2 100644 --- a/test/e2e/tests/Feature.AgentPopup.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentPopup.Tests.ps1 @@ -5,13 +5,13 @@ # Invoke-Pester test/e2e/tests -Tag Feature BeforeDiscovery { - $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) + $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue)) } Describe 'Feature: agent pane popup + menu' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot' } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null } diff --git a/test/e2e/tests/Feature.AgentRestart.Tests.ps1 b/test/e2e/tests/Feature.AgentRestart.Tests.ps1 index 638cd9568..4e80fe4ad 100644 --- a/test/e2e/tests/Feature.AgentRestart.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentRestart.Tests.ps1 @@ -3,12 +3,12 @@ # Shift+Enter (focus) on a live session row. # Invoke-Pester test/e2e/tests -Tag Feature -BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) } +BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue)) } Describe 'Feature: agent restart + session focus' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot' } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null } diff --git a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 index 4c8743bb1..6890b1365 100644 --- a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 +++ b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 @@ -5,14 +5,26 @@ # repeated identical corrections within one session, so tests that need a FRESH card each # (Insert / Run / Stashed) use their own fresh terminal and trigger exactly once. -BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) } +BeforeDiscovery { + # winapp (UI-automation CLI) is required by Open-AgentPane / agent-pane assertions; gate on + # it too so the whole suite SKIPS cleanly when it's missing instead of throwing in BeforeAll. + $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue)) + # WSL-gated cases need: the dev sideload package (OSC 9001;ShellType + the autofix + # shell-context fix aren't in the Store build yet), copilot for autofix, winapp for the + # agent pane, and a runnable WSL distro. Absent any of these the WSL Describe is skipped. + $script:WslReady = $false + $devPkg = Get-AppxPackage | Where-Object { $_.PackageFamilyName -like 'IntelligentTerminal_*' } + if ($devPkg -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue) -and (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + try { & wsl.exe -e true 2>$null; $script:WslReady = ($LASTEXITCODE -eq 0) } catch { $script:WslReady = $false } + } +} # Helpers as script scriptblocks set up per-Describe in BeforeAll. Describe 'Feature: autofix card render + reject + AI correctness' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null $script:CardShown = { ((Get-AgentPaneText -App $script:app -MaxLines 60) -match 'Run command|Insert in Terminal') } @@ -55,7 +67,7 @@ Describe 'Feature: autofix card render + reject + AI correctness' -Tag 'Feature' Describe 'Feature: autofix Insert action' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null } @@ -73,7 +85,7 @@ Describe 'Feature: autofix Insert action' -Tag 'Feature' -Skip:(-not $script:Rea Start-Sleep -Milliseconds 400 Invoke-FailingCommand -App $script:app -SessionId $sid -Command $cmd | Out-Null Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null - } finally { Stop-WtEventListener -Listener $listener } + } catch { } finally { Stop-WtEventListener -Listener $listener } if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match 'Insert in Terminal' }) { $gotCard = $true; break } } if (-not $gotCard) { Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)'; return } @@ -88,7 +100,7 @@ Describe 'Feature: autofix Insert action' -Tag 'Feature' -Skip:(-not $script:Rea Describe 'Feature: autofix Run action' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null } @@ -104,7 +116,7 @@ Describe 'Feature: autofix Run action' -Tag 'Feature' -Skip:(-not $script:Ready) Start-Sleep -Milliseconds 400 Invoke-FailingCommand -App $script:app -SessionId $sid -Command $cmd | Out-Null Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null - } finally { Stop-WtEventListener -Listener $listener } + } catch { } finally { Stop-WtEventListener -Listener $listener } if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match 'Run command' }) { $gotCard = $true; break } } if (-not $gotCard) { Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)'; return } @@ -118,7 +130,7 @@ Describe 'Feature: autofix Run action' -Tag 'Feature' -Skip:(-not $script:Ready) Describe 'Feature: autofix on stashed pane' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } # Pre-warm + connect, then stash so the helper is ready but the pane is hidden. Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null @@ -141,7 +153,7 @@ Describe 'Feature: autofix on stashed pane' -Tag 'Feature' -Skip:(-not $script:R Describe 'Feature: autofix across layout changes' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null } @@ -164,7 +176,87 @@ Describe 'Feature: autofix across layout changes' -Tag 'Feature' -Skip:(-not $sc { Close-WtPane -App $script:app -SessionId $tab.session_id } | Should -Not -Throw { Get-ActivePane -App $script:app } | Should -Not -Throw } -} +} + +# ───────────────────────────────────────────────────────────────────────────── +# WSL-gated: exercises OSC 9001;ShellType end-to-end and the autofix shell-context +# fix that consumes it. Runs ONLY when a runnable WSL distro + the dev package are +# present (the feature isn't in the Store build); otherwise the whole Describe is +# skipped. Requires the dev package, so it targets -Package Dev explicitly. +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Feature: autofix in a WSL pane (OSC 9001;ShellType end-to-end)' -Tag 'Feature' -Skip:(-not $script:WslReady) { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force + # Dev package: the ShellType plumbing and the autofix shell-context fix live + # here, not in the Store build. + $script:app = Start-Terminal -Package Dev -PassFre $true -Settings @{ acpAgent = 'copilot'; autoFixEnabled = $true } + + # Open a WSL shell pane in a fresh tab, then focus it so it's the active tab: + # the agent pane is per-tab, and Get-AgentPaneText / Open-AgentPane target the + # active tab's helper. Each tab gets its own pre-warmed helper, so autofix + # works on this tab. + $script:wsl = New-WtTab -App $script:app -Command 'wsl.exe' -Title 'wsl-autofix' + $script:wslSid = $script:wsl.session_id + $script:wslTabId = $script:wsl.tab_id + $script:wslWinId = $script:wsl.window_id + Set-WtPaneFocus -App $script:app -SessionId $script:wslSid + + # Nudge a prompt so bash shell integration emits OSC 9001;ShellType at least + # once, then wait for the reported shell to surface through the protocol. + # list-panes is active-tab scoped, so query the WSL tab explicitly by id — + # this is robust even if focus drifts. If the shell never surfaces, the distro + # has no shell integration installed; the per-It guards below skip not fail. + Send-WtKeys -App $script:app -SessionId $script:wslSid -Keys @('Enter') + $script:wslShell = $null + Test-Until -TimeoutSec 30 -IntervalSec 1 -Condition { + $p = Get-WtPanes -App $script:app -TabId $script:wslTabId -WindowId $script:wslWinId | + Where-Object { $_.session_id -eq $script:wslSid } + if ($p -and ($p.shell -match '^(?i)wsl:')) { $script:wslShell = $p.shell; $true } else { $false } + } | Out-Null + + # Agent pane on the (now active) WSL tab so autofix cards render here. + Open-AgentPane -App $script:app | Out-Null + Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null + } + AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } + + It 'WSL pane self-reports its shell as wsl: through the protocol' { + if (-not $script:wslShell) { + Set-ItResult -Skipped -Because 'WSL pane never reported a wsl: shell (shell integration not installed in this distro)' + return + } + # Proves OSC 9001;ShellType -> adaptDispatch -> Terminal -> ControlCore -> + # PaneInfo -> COM JSON, including the distro name surviving the ';' split. + $script:wslShell | Should -Match '^(?i)wsl:\S' + } + + It 'Autofix suggests a Linux/bash fix (not PowerShell) in a WSL pane' { + if (-not $script:wslShell) { + Set-ItResult -Skipped -Because 'WSL shell integration not installed; autofix has no WSL shell context to read' + return + } + # bash-typos whose correct form is unmistakably Linux (ls/grep/cat), so the AI + # oracle can tell a bash fix from a PowerShell one (Get-ChildItem etc.). The + # whole point of the fix under test: with shell=wsl: in the prompt the + # agent must NOT fall back to PowerShell syntax. + $typos = @('sl -la', 'lll', 'grpe root /etc/hostname', 'caat /etc/hostname') + $gotCard = $false + foreach ($cmd in $typos) { + $listener = Start-WtEventListener -App $script:app + try { + Start-Sleep -Milliseconds 400 + Invoke-FailingCommand -App $script:app -SessionId $script:wslSid -Command $cmd | Out-Null + Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null + } catch { } finally { Stop-WtEventListener -Listener $listener } + if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match 'Run command|Insert in Terminal' }) { $gotCard = $true; break } + } + if (-not $gotCard) { + Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)' + return + } + Assert-AI -Claim 'The suggested fix command uses Linux/bash shell syntax (e.g. ls, grep, cat, forward-slash paths). It is NOT a Windows PowerShell command (no Get-ChildItem / Select-String / cmdlet-style Verb-Noun).' -Context (Get-AgentPaneText -App $script:app -MaxLines 60) + } +} diff --git a/test/e2e/tests/Feature.FreExecutionPolicy.Tests.ps1 b/test/e2e/tests/Feature.FreExecutionPolicy.Tests.ps1 index e61e3ed95..c57aad7e3 100644 --- a/test/e2e/tests/Feature.FreExecutionPolicy.Tests.ps1 +++ b/test/e2e/tests/Feature.FreExecutionPolicy.Tests.ps1 @@ -34,6 +34,9 @@ BeforeDiscovery { # of the suite cleanly skipping. $script:DevReady = $false try { $null = Resolve-ItApp -Package Dev -ErrorAction Stop; $script:DevReady = $true } catch { $script:DevReady = $false } + # winapp drives the FRE overlay via UIA; without it BeforeAll would throw, so fold it into + # the gate and skip the suite cleanly instead. + $script:DevReady = $script:DevReady -and (Test-WinAppAvailable) # A Group Policy execution-policy override (MachinePolicy/UserPolicy) outranks the HKCU # CurrentUser scope these tests force, making the FRE verdict non-deterministic — skip the # whole suite when one is in effect rather than assert against an uncontrollable policy. diff --git a/test/e2e/tests/Feature.FreFlow.Tests.ps1 b/test/e2e/tests/Feature.FreFlow.Tests.ps1 index d1135f88f..4fc8a908f 100644 --- a/test/e2e/tests/Feature.FreFlow.Tests.ps1 +++ b/test/e2e/tests/Feature.FreFlow.Tests.ps1 @@ -3,14 +3,14 @@ # (the FRE is a XAML overlay with AutomationId buttons NextButton / SaveButton). # Invoke-Pester test/e2e/tests -Tag Feature -BeforeDiscovery { $script:Ready = [bool](Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) } +BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command winapp -ErrorAction SilentlyContinue)) } Describe 'Feature §0 FRE overlay flow' -Tag 'Feature' -Skip:(-not $script:Ready) { Context 'FRE opens' { It 'FRE opens correctly (Welcome page shows on a fresh profile)' { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $app = Start-TerminalFre -Package Store + $app = Start-TerminalFre -Package (Get-ItTestPackage) try { Test-UiElementExists -App $app -Selector 'WelcomePage' -TimeoutSec 10 | Should -BeTrue Test-UiElementExists -App $app -Selector 'NextButton' -TimeoutSec 5 | Should -BeTrue @@ -22,7 +22,7 @@ Describe 'Feature §0 FRE overlay flow' -Tag 'Feature' -Skip:(-not $script:Ready Context 'FRE privacy/help link' { It 'FRE privacy / help link is present on the welcome page' { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $app = Start-TerminalFre -Package Store + $app = Start-TerminalFre -Package (Get-ItTestPackage) try { $tree = Get-UiTree -App $app -Depth 8 $tree | Should -Match 'Learn more|privacy|Privacy' @@ -34,7 +34,7 @@ Describe 'Feature §0 FRE overlay flow' -Tag 'Feature' -Skip:(-not $script:Ready Context 'FRE completion' { It 'FRE can be completed (Next -> Save dismisses the overlay and marks complete)' { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $app = Start-TerminalFre -Package Store + $app = Start-TerminalFre -Package (Get-ItTestPackage) try { Test-FreShowing -App $app | Should -BeTrue Invoke-UiElement -App $app -Selector 'NextButton' -TimeoutSec 10 | Out-Null @@ -52,7 +52,7 @@ Describe 'Feature §0 FRE overlay flow' -Tag 'Feature' -Skip:(-not $script:Ready It 'FRE save progress / completion leaves a usable terminal (settings valid)' { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $app = Start-TerminalFre -Package Store + $app = Start-TerminalFre -Package (Get-ItTestPackage) try { Invoke-UiElement -App $app -Selector 'NextButton' -TimeoutSec 10 | Out-Null Wait-UiElement -App $app -Selector 'SaveButton' -TimeoutSec 10 | Out-Null @@ -71,12 +71,12 @@ Describe 'Feature §0 FRE overlay flow' -Tag 'Feature' -Skip:(-not $script:Ready Context 'FRE close safety' { It 'FRE can be closed safely (closing the window mid-FRE leaves settings valid)' { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $app = Start-TerminalFre -Package Store + $app = Start-TerminalFre -Package (Get-ItTestPackage) Test-FreShowing -App $app | Should -BeTrue # Closing the window during FRE must not corrupt settings/state. Stop-Terminal -App $app # Relaunch (FRE was never completed, so settings.json must still parse). - $app2 = Start-Terminal -Package Store -PassFre $true + $app2 = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true try { (Get-WtSettingsObject -App $app2) | Should -Not -BeNullOrEmpty } diff --git a/test/e2e/tests/Feature.Packaging.Tests.ps1 b/test/e2e/tests/Feature.Packaging.Tests.ps1 index 1ac3d4258..1d3758b13 100644 --- a/test/e2e/tests/Feature.Packaging.Tests.ps1 +++ b/test/e2e/tests/Feature.Packaging.Tests.ps1 @@ -4,12 +4,17 @@ # Maps to checklist items (auto): packaged wta present, packaged identity, WT_COM_CLSID, # wtcli list-panes/capture-pane/send-keys/listen, master+helper start, logs+version dir. -BeforeDiscovery { $script:Ready = [bool](Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) } +BeforeDiscovery { + $script:Ready = [bool](Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) + # §10 opens the agent pane (UI automation) so it additionally needs winapp; §9 is + # pure protocol/packaging and runs without it. + $script:UiReady = $script:Ready -and [bool](Get-Command winapp -ErrorAction SilentlyContinue) +} Describe 'Feature §9 Packaging + protocol' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } @@ -73,10 +78,10 @@ Describe 'Feature §9 Packaging + protocol' -Tag 'Feature' -Skip:(-not $script:R } } -Describe 'Feature §10 Diagnostics + logging' -Tag 'Feature' -Skip:(-not $script:Ready) { +Describe 'Feature §10 Diagnostics + logging' -Tag 'Feature' -Skip:(-not $script:UiReady) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null } diff --git a/test/e2e/tests/Feature.SessionList.Tests.ps1 b/test/e2e/tests/Feature.SessionList.Tests.ps1 index c09270b66..2b879dc79 100644 --- a/test/e2e/tests/Feature.SessionList.Tests.ps1 +++ b/test/e2e/tests/Feature.SessionList.Tests.ps1 @@ -3,12 +3,12 @@ # (the agent-pane session-management view). Deterministic where possible; AI only for # semantic checks. -BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) } +BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue) -and (Get-Command winapp -ErrorAction SilentlyContinue)) } Describe 'Feature: session list + view switching + focus/restore' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true -Settings @{ acpAgent = 'copilot' } + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } Open-AgentPane -App $script:app | Out-Null Wait-AgentReady -App $script:app -TimeoutSec 60 | Out-Null # Seed a live session with a known marker so a row exists. diff --git a/test/e2e/tests/Feature.Settings.Tests.ps1 b/test/e2e/tests/Feature.Settings.Tests.ps1 index c3f6f5617..ba4b2fdcd 100644 --- a/test/e2e/tests/Feature.Settings.Tests.ps1 +++ b/test/e2e/tests/Feature.Settings.Tests.ps1 @@ -9,7 +9,7 @@ BeforeDiscovery { $script:Ready = [bool](Get-AppxPackage | Where-Object { $_.Nam Describe 'Feature §1 Settings > AI Agents' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } @@ -60,7 +60,7 @@ Describe 'Feature §1 Settings > AI Agents' -Tag 'Feature' -Skip:(-not $script:R Describe 'Feature §0 FRE settings, positions, auto-error, session mgmt' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force - $script:app = Start-Terminal -Package Store -PassFre $true + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 5b62b25f4..dadbdfe7f 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -985,11 +985,29 @@ fn process_image_name(_pid: u32) -> Option { None } -/// Resolve the canonical shell exe from an active-pane JSON object's `pid` -/// field (already present in `get_active_pane`/`get_panes` responses). The -/// agent gets this as the `shell` field — the sole shell-type signal, since -/// the WT profile *name* (which the user can rename) is no longer shipped. +/// Resolve the shell identity for an active-pane JSON object. The agent gets +/// this as the `shell` field — the shell-type signal that drives PowerShell vs +/// bash vs cmd syntax in any fix command it suggests. +/// +/// Resolution order: +/// 1. The `shell` field reported by shell integration via `OSC 9001;ShellType` +/// (e.g. `pwsh`, `powershell`, `bash`, `wsl:Ubuntu`). This is the only +/// signal that survives a nested shell — `pwsh` → `wsl` → `exit` reports +/// `wsl:` while inside WSL and `pwsh` again after exit, because the +/// shell re-emits it on every prompt. The pid-based fallback below can't +/// see this: the pane's host process stays `wsl.exe`/`pwsh.exe` regardless +/// of which shell is actually drawing the prompt. +/// 2. Otherwise, the canonical shell exe from the pane's `pid` (covers panes +/// without shell integration installed, or before the first prompt). fn shell_from_active(active: &serde_json::Value) -> Option { + if let Some(shell) = active + .get("shell") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return Some(shell.to_string()); + } active .get("pid") .and_then(|v| v.as_u64()) @@ -3550,6 +3568,25 @@ mod tests { assert_eq!(shell_from_active(&serde_json::json!({})), None); } + /// The `shell` field reported via `OSC 9001;ShellType` wins over the + /// pid-based fallback — even when a real pid is present. This is the + /// nested-shell case (`pwsh` → `wsl` → bash): the pane's host process is + /// still pwsh/wsl.exe, but the prompt is drawn by bash, so the OSC-reported + /// `wsl:Ubuntu` must reach the agent. Platform-independent (no pid lookup). + #[test] + fn shell_from_active_prefers_osc_reported_shell() { + // Reported shell wins over a live pid. + let pane = serde_json::json!({ "pid": std::process::id(), "shell": "wsl:Ubuntu" }); + assert_eq!(shell_from_active(&pane), Some("wsl:Ubuntu".to_string())); + + // Empty/whitespace reported shell is ignored; falls back to pid (or None). + assert_eq!( + shell_from_active(&serde_json::json!({ "shell": " ", "pid": 0 })), + None + ); + assert_eq!(shell_from_active(&serde_json::json!({ "shell": "" })), None); + } + /// Helper-only: round-trip a `_meta` blob through `inject_wta_pane_meta` /// and report the `pane_session_id` that the master would see in /// `extract_wta_meta`. Returns `None` when the meta is empty after