From f3697b8c30776b7eb031b35bd29adac20d43c2a4 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Tue, 4 Aug 2026 10:51:46 +0800 Subject: [PATCH] fix: address the four alpha1 field reports Local testing of v1.3.0-alpha1 reported four issues. This change fixes all four ahead of the 1.3.0 beta. 1. The Command Timeline handle covered terminal content. The floating 28x48 button is now a 6-pixel strip flush against the terminal's left edge, in the manner of an auto-hiding scrollbar. It widens to 20 pixels and shows its chevron on pointer hover, keyboard focus, or while the overlay is open, driven by _updateCommandTimelineHandleVisual. 2. Clicking the terminal area while the overlay was open did not close it. A pointer press on the terminal grid now light-dismisses the overlay and still continues into the terminal as normal input. Presses on the overlay itself never reach that handler, so overlay interaction is unaffected. 3. A dir listing left the Visual Progress bar animating indefinitely. Three recognition defects compounded: - _matchGradle claimed ownership from a bare product-name mention (for example a ".gradle" directory entry) and, once claimed, rematched every later record through an unconditional stage fallback, refreshing the bar forever. Each record now needs build-tool evidence of its own: a status meter with a real value, a wrapper download, or a task line. - A still-running built-in provider bar was never structurally cleared when its stream moved on; only Generic was. The engine now tolerates one ordinary record and publishes a structural clear on the second consecutive ordinary record. Success and Error results still persist, and progress-shaped records that keep a live claim do not advance the count. - _findIntegerFraction read slashed dates such as 2025/10/13 as 76% completed/total meters. A digit/digit/digit chain is now rejected. 4. Typed commands never appeared in the Command Timeline because nothing ever imported the packaged winTerm.Shell module: the launcher contract in docs/powershell-integration.md had no launcher. Connection creation now rewrites a bare powershell.exe or pwsh.exe profile commandline (only -NoLogo and -NoExit are tolerated) to append -NoExit -Command with a fragment that sets WINTERM_SESSION_ID and WINTERM_INTEGRATION_VERSION and imports the packaged manifest with -ErrorAction SilentlyContinue. The eligibility rules live in src/winterm/Shell/AutoIntegration.h; anything not positively recognized launches unchanged, execution policy is never altered, quote characters in the manifest path or session id refuse the rewrite, and an already-rewritten commandline reused by a restarted connection is not rewritten twice. The new per-profile setting "shellIntegration.autoInject" (default true) disables the rewrite. Guards in test-command-timeline.ps1 now assert the auto-hiding handle and the light-dismiss path. New unit tests cover the listing false positives, the structural clear, terminal-state persistence, and the auto-integration eligibility rules including its refusal cases. No persistent history, no output cache, no heuristic prompt detection, no telemetry. Workspace schema, docking model, shell protocol, theme schema, update manifest schema, package identity, and signing policy are unchanged. --- CHANGELOG.md | 34 +++ doc/cascadia/profiles.schema.json | 5 + docs/current-progress.md | 32 ++- docs/powershell-integration.md | 29 ++- scripts/winterm/test-command-timeline.ps1 | 10 +- src/cascadia/TerminalApp/TerminalPage.cpp | 48 ++++- src/cascadia/TerminalControl/TermControl.cpp | 54 ++++- src/cascadia/TerminalControl/TermControl.h | 7 + src/cascadia/TerminalControl/TermControl.xaml | 27 ++- .../TerminalSettingsModel/MTSMSettings.h | 1 + .../TerminalSettingsModel/Profile.idl | 1 + .../WinTermShellTests.cpp | 68 ++++++ .../WinTermVisualProgressTests.cpp | 82 ++++++- src/winterm/Shell/AutoIntegration.h | 200 ++++++++++++++++++ .../VisualProgress/ProgressRecognition.h | 74 ++++++- 15 files changed, 643 insertions(+), 29 deletions(-) create mode 100644 src/winterm/Shell/AutoIntegration.h diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d96742e..43e777ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## Unreleased + +Fixes for the four alpha1 field reports, ahead of the 1.3.0 beta. + +### Added + +- Automatic PowerShell shell integration. When a profile's commandline is a + bare `powershell.exe` or `pwsh.exe` invocation (optionally with `-NoLogo` or + `-NoExit`), winTerm now appends a `-NoExit -Command` fragment that imports + the packaged `winTerm.Shell` module, so OSC 133 marks — and therefore the + Command Timeline — work out of the box for the stock Windows PowerShell + profile. Any customized invocation launches unchanged, execution policy is + never altered, and an import failure leaves a working shell. The new + per-profile setting `"shellIntegration.autoInject"` (default `true`) turns + the rewrite off. This fixes typed commands never appearing in the Command + Timeline on a fresh install. + +### Fixed + +- The Command Timeline handle no longer covers terminal content. It now rests + as a 6-pixel strip flush against the terminal's left edge, in the manner of + an auto-hiding scrollbar, and widens to show its chevron on hover, keyboard + focus, or while the overlay is open. +- Clicking the terminal area while the Command Timeline is open now + light-dismisses the overlay; the click still reaches the terminal. +- The Visual Progress bar no longer keeps animating after ordinary output such + as a `dir` listing. Recognition of Gradle-style output now requires + per-record build-tool evidence instead of a bare product-name mention, an + established provider claim no longer rematches arbitrary later records, a + still-running provider bar structurally clears after two consecutive + ordinary records, and slashed dates such as `2025/10/13` are no longer read + as completed/total meters. Success and Error results still persist until a + later publication replaces them. + ## 1.3.0-alpha1 - 2026-08-04 First public prerelease of the Command Timeline. This is an alpha for local diff --git a/doc/cascadia/profiles.schema.json b/doc/cascadia/profiles.schema.json index 1cc61ff3d..d09687177 100644 --- a/doc/cascadia/profiles.schema.json +++ b/doc/cascadia/profiles.schema.json @@ -2979,6 +2979,11 @@ "description": "When set to true, prompts will automatically be marked.", "type": "boolean" }, + "shellIntegration.autoInject": { + "default": true, + "description": "When set to true and the profile commandline is a bare PowerShell invocation, winTerm imports its packaged shell integration module at startup so command marks work without profile changes. Customized commandlines are never rewritten.", + "type": "boolean" + }, "experimental.autoMarkPrompts": { "type": "boolean", "description": "[Deprecated] Replaced with the \"autoMarkPrompts\" setting.", diff --git a/docs/current-progress.md b/docs/current-progress.md index a1fd382da..597e6124f 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -4,8 +4,9 @@ Last updated: 2026-08-04 ## Repository state -- Branch: `release/v1.3.0-alpha1` -- Base branch: `main` at `ac760eab` (Command Timeline Phase 4, pull request #30) +- Branch: `fix/alpha1-feedback` +- Base branch: `main` at `ed707550` (1.3.0-alpha1 release metadata, pull + request #31) - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` - Application version: `1.3.0-alpha1` @@ -47,13 +48,32 @@ prerelease suffix. The package version stays four-part numeric for MSIX and the Win32 resource fields, and the PowerShell module version stays numeric with the suffix carried in `PrivateData.PSData.Prerelease`. +## Alpha1 field reports + +Local testing of `v1.3.0-alpha1` surfaced four issues, all addressed on this +branch: + +1. The Timeline handle covered terminal content. It is now a thin auto-hiding + strip on the terminal's left edge that widens on hover, focus, or while the + overlay is open. +2. Clicking the terminal area did not close an open Timeline. The overlay now + light-dismisses on a terminal press, which still reaches the terminal. +3. A `dir` listing left the Visual Progress bar animating indefinitely. The + recognition engine no longer claims ownership from a bare product-name + mention, no longer rematches arbitrary records under an established claim, + structurally clears a still-running bar after two consecutive ordinary + records, and no longer reads slashed dates as meters. +4. Typed commands never appeared in the Timeline because nothing imported the + packaged `winTerm.Shell` module. Bare PowerShell profile commandlines are + now rewritten at connection creation to import it, gated by the new + per-profile setting `"shellIntegration.autoInject"` (default `true`). + ## Next steps -1. Install `v1.3.0-alpha1` locally and exercise the Command Timeline. -2. Fix anything the alpha testing surfaces. -3. Cut `v1.3.0-beta1` on channel `beta`. The beta may be listed on the winTerm +1. Re-test the four fixes locally on a fresh build. +2. Cut `v1.3.0-beta1` on channel `beta`. The beta may be listed on the winTerm website alongside the stable v1.2.0 download. -4. Promote to a stable `v1.3.0` only after beta testing, which is the point at +3. Promote to a stable `v1.3.0` only after beta testing, which is the point at which Latest, WinGet, and the website stable slot move. ## Validation state diff --git a/docs/powershell-integration.md b/docs/powershell-integration.md index 560fe6ba2..63a105804 100644 --- a/docs/powershell-integration.md +++ b/docs/powershell-integration.md @@ -1,8 +1,8 @@ # PowerShell integration -The packaged module is `ShellAssets\powershell\winTerm.Shell\winTerm.Shell.psd1`, version `1.0.2`. It supports PowerShell 7 and Windows PowerShell 5.1 with the same syntax. +The packaged module is `ShellAssets\powershell\winTerm.Shell\winTerm.Shell.psd1`; its module version tracks the winTerm release. It supports PowerShell 7 and Windows PowerShell 5.1 with the same syntax. -An explicit winTerm profile launcher must set these process-local variables before importing the module: +A winTerm launcher must set these process-local variables before importing the module: ```powershell $env:WINTERM_SESSION_ID = '' @@ -12,6 +12,31 @@ Import-Module '\ShellAssets\powershell\winTerm.Shell\winTerm.Shell The module does not add this block to `$PROFILE`. A launcher must preserve normal PowerShell execution policy; the module neither uses nor recommends `-ExecutionPolicy Bypass`. If policy prevents importing a module, PowerShell must still launch and diagnostics should report the failure and recommend a user-reviewed policy or installation remedy. +## Automatic integration for bare PowerShell profiles + +winTerm performs the launcher steps automatically when a profile's commandline +is a bare PowerShell invocation, so the Command Timeline and shell-lifecycle +progress work out of the box for the stock Windows PowerShell profile. The +rules are deliberately narrow and are implemented in +`src/winterm/Shell/AutoIntegration.h`: + +- Only `powershell.exe` and `pwsh.exe` are recognized, by executable basename. +- The only arguments tolerated on the original commandline are `-NoLogo` and + `-NoExit`. Any other argument — including `-Command`, `-File`, + `-EncodedCommand`, `-NoProfile`, or `-ExecutionPolicy` — means the user has + customized the invocation, and it launches unchanged. +- The rewrite appends `-NoExit -Command` with a fragment that sets the two + session variables and imports the packaged module with + `-ErrorAction SilentlyContinue`. Execution policy is never altered, and an + import failure leaves a working shell without integration. +- A commandline that already mentions the module is not rewritten again, so a + restarted connection stays stable. + +The per-profile setting `"shellIntegration.autoInject"` (default `true`) +disables the rewrite when set to `false`. Because `-Command` is present on the +rewritten invocation, PowerShell suppresses its startup banner; this is the +standard behavior of every launcher-based shell integration. + ## Prompt and marks On an eligible session, the module captures the current `prompt` function, sends prompt/CWD marks around its output, and calls the original script block. A second import detects its own wrapper instead of nesting it. Removing `winTerm.Shell` restores the captured prompt when the wrapper is still active. This preserves common profile customizations, including prompt frameworks loaded before the module. diff --git a/scripts/winterm/test-command-timeline.ps1 b/scripts/winterm/test-command-timeline.ps1 index 55281d814..74f03e313 100644 --- a/scripts/winterm/test-command-timeline.ps1 +++ b/scripts/winterm/test-command-timeline.ps1 @@ -186,10 +186,12 @@ if ($overlayStart -lt 0 -or $rendererNoticeStart -le $overlayStart) { $overlayXaml = $termControlXaml.Substring($overlayStart, $rendererNoticeStart - $overlayStart) Assert-Contains -Content $overlayXaml -Values @( 'x:Name="CommandTimelineHandle"', - 'Margin="8,0,0,0"', + 'Width="6"', + 'PointerEntered="_CommandTimelineHandlePointerEntered"', + 'PointerExited="_CommandTimelineHandlePointerExited"', 'x:Name="CommandTimelineList"', 'SelectionMode="Single"' -) -Failure 'The overlay handle, bounded list, or selection presentation is incomplete.' +) -Failure 'The overlay auto-hiding handle, bounded list, or selection presentation is incomplete.' if ($overlayXaml.Contains('SwapChainPanel') -or $overlayXaml.Contains('ColumnDefinition') -or $overlayXaml.Contains('Storyboard')) { throw 'The Timeline overlay must not resize the terminal or add an independent animation loop.' } @@ -207,11 +209,13 @@ Assert-Contains -Content $termControlSource -Values @( 'GetTSFHandle().HasActiveComposition()', '_tryHandleCommandTimelineKey(vkey, modifiers, keyDown)', '_tryHandleCommandTimelineWheel(point.Position(), delta)', + '_commandTimelineOpen && !_isPointOverCommandTimeline(point.Position())', + '_updateCommandTimelineHandleVisual()', 'TextTrimming::CharacterEllipsis', '_commandTimelineWheelSettleTimer.Stop()', 'CommandTimelineList().Items().Clear()', 'Focus(FocusState::Programmatic)' -) -Failure 'Timeline input isolation, IME precedence, snapping, or close cleanup is incomplete.' +) -Failure 'Timeline input isolation, IME precedence, light dismiss, or close cleanup is incomplete.' if ($termControlSource.IndexOf('_TryHandleKeyBinding(vkey, scanCode, modifiers)', [StringComparison]::Ordinal) -gt $termControlSource.IndexOf('_tryHandleCommandTimelineKey(vkey, modifiers, keyDown)', [StringComparison]::Ordinal)) { throw 'User-defined key bindings must retain precedence over bare Timeline navigation.' diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 9440e6ccb..1d0670ddf 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -27,6 +27,7 @@ #include "TerminalSettingsCache.h" #include "VisualProgressWindowCoordinator.h" #include "../../winterm/Design/DesignTokens.h" +#include "../../winterm/Shell/AutoIntegration.h" #include "LaunchPositionRequest.g.cpp" #include "RenameWindowRequestedArgs.g.cpp" @@ -1537,6 +1538,43 @@ namespace winrt::TerminalApp::implementation // - the terminal settings // Return value: // - the desired connection + // Rewrites a bare PowerShell profile commandline so the packaged + // winTerm.Shell module provides shell integration marks for that session. + // Anything the eligibility rules do not positively recognize launches + // unchanged; a missing packaged module disables the rewrite entirely. + static std::optional _buildAutoIntegratedShellCommandline(const std::wstring_view commandline) + { + static const auto moduleManifestPath = []() -> std::wstring { + try + { + const std::filesystem::path root{ wil::GetModuleFileNameW(nullptr) }; + auto candidate = root.parent_path() / L"ShellAssets" / L"powershell" / L"winTerm.Shell" / L"winTerm.Shell.psd1"; + std::error_code ec; + if (std::filesystem::exists(candidate, ec)) + { + return candidate.wstring(); + } + } + CATCH_LOG(); + return {}; + }(); + + if (moduleManifestPath.empty()) + { + return std::nullopt; + } + + GUID sessionId{}; + if (FAILED(CoCreateGuid(&sessionId))) + { + return std::nullopt; + } + + return winTerm::Shell::BuildAutoIntegratedPowerShellCommandline(commandline, + moduleManifestPath, + ::Microsoft::Console::Utils::GuidToString(sessionId)); + } + TerminalConnection::ITerminalConnection TerminalPage::_CreateConnectionFromSettings(Profile profile, IControlSettings settings, const bool inheritCursor) @@ -1601,7 +1639,15 @@ namespace winrt::TerminalApp::implementation // restored the CWD to its original value. auto newWorkingDirectory{ _evaluatePathForCwd(settings.StartingDirectory()) }; connection = TerminalConnection::ConptyConnection{}; - valueSet = TerminalConnection::ConptyConnection::CreateSettings(settings.Commandline(), + auto commandline = settings.Commandline(); + if (profile.AutoInjectShellIntegration()) + { + if (const auto integrated = _buildAutoIntegratedShellCommandline(commandline)) + { + commandline = winrt::hstring{ *integrated }; + } + } + valueSet = TerminalConnection::ConptyConnection::CreateSettings(commandline, newWorkingDirectory, settings.StartingTitle(), settingsInternal->ReloadEnvironmentVariables(), diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 9adc1af22..6f1613515 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -2003,6 +2003,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation const auto point = args.GetCurrentPoint(*this); const auto type = ptr.PointerDeviceType(); + // A press on the terminal area light-dismisses the Command Timeline. + // The press still continues into the terminal below, so the click + // also does whatever it would normally do there. Presses on the + // overlay itself never reach this handler. + if (_commandTimelineOpen && !_isPointOverCommandTimeline(point.Position())) + { + _closeCommandTimeline(false); + } + // GH#19908: _focused can be true even when the search box has // keyboard focus, because GotFocus bubbles from the search box // child and _GotFocusHandler sets _focused=true. If the user @@ -2771,6 +2780,48 @@ namespace winrt::Microsoft::Terminal::Control::implementation ToggleCommandTimeline(); } + // The handle rests as a thin strip on the terminal's left edge and only + // widens while it is hovered, focused, or the overlay is open, so it + // does not cover terminal content in normal use. + void TermControl::_updateCommandTimelineHandleVisual() + { + static constexpr double restingWidth{ 6.0 }; + static constexpr double expandedWidth{ 20.0 }; + const auto expanded = _commandTimelineOpen || + _commandTimelineHandlePointerOver || + _commandTimelineHandleFocused; + CommandTimelineHandle().Width(expanded ? expandedWidth : restingWidth); + CommandTimelineHandleIcon().Visibility(expanded ? Visibility::Visible : Visibility::Collapsed); + } + + void TermControl::_CommandTimelineHandlePointerEntered(const IInspectable& /*sender*/, + const Input::PointerRoutedEventArgs& /*args*/) + { + _commandTimelineHandlePointerOver = true; + _updateCommandTimelineHandleVisual(); + } + + void TermControl::_CommandTimelineHandlePointerExited(const IInspectable& /*sender*/, + const Input::PointerRoutedEventArgs& /*args*/) + { + _commandTimelineHandlePointerOver = false; + _updateCommandTimelineHandleVisual(); + } + + void TermControl::_CommandTimelineHandleGotFocus(const IInspectable& /*sender*/, + const RoutedEventArgs& /*args*/) + { + _commandTimelineHandleFocused = true; + _updateCommandTimelineHandleVisual(); + } + + void TermControl::_CommandTimelineHandleLostFocus(const IInspectable& /*sender*/, + const RoutedEventArgs& /*args*/) + { + _commandTimelineHandleFocused = false; + _updateCommandTimelineHandleVisual(); + } + void TermControl::_CommandTimelineSelectionChanged(const IInspectable& /*sender*/, const Controls::SelectionChangedEventArgs& /*args*/) { @@ -3042,6 +3093,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation Windows::UI::Xaml::Automation::AutomationProperties::SetName(CommandTimelineHandle(), RS_(L"CommandTimelineOpen")); Controls::ToolTipService::SetToolTip(CommandTimelineHandle(), box_value(RS_(L"CommandTimelineOpen"))); CommandTimelineHandleIcon().Glyph(L"\xE76C"); + _updateCommandTimelineHandleVisual(); if (returnFocus && !_IsClosing()) { @@ -3534,10 +3586,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation { _commandTimelineOpen = true; CommandTimelineOverlay().Visibility(Visibility::Visible); - CommandTimelineHandle().Margin({ 8, 0, 0, 0 }); Windows::UI::Xaml::Automation::AutomationProperties::SetName(CommandTimelineHandle(), RS_(L"CommandTimelineClose")); Controls::ToolTipService::SetToolTip(CommandTimelineHandle(), box_value(RS_(L"CommandTimelineClose"))); CommandTimelineHandleIcon().Glyph(L"\xE76B"); + _updateCommandTimelineHandleVisual(); const auto width = std::clamp(ActualWidth() * 0.42, 180.0, 360.0); CommandTimelineOverlay().Width(std::max(1.0, std::min(width, ActualWidth()))); diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index 6109eb63d..a2a6f112c 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -322,6 +322,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool _pointerPressedInBounds{ false }; bool _commandTimelineOpen{ false }; bool _updatingCommandTimelineSelection{ false }; + bool _commandTimelineHandlePointerOver{ false }; + bool _commandTimelineHandleFocused{ false }; std::array _commandTimelineConsumedKeys{}; std::optional _commandTimelinePendingLoad; @@ -389,6 +391,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation void _MouseWheelHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); void _CommandTimelineWheelHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); void _CommandTimelineHandleClick(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::RoutedEventArgs& e); + void _CommandTimelineHandlePointerEntered(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _CommandTimelineHandlePointerExited(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _CommandTimelineHandleGotFocus(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::RoutedEventArgs& e); + void _CommandTimelineHandleLostFocus(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::RoutedEventArgs& e); void _CommandTimelineSelectionChanged(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Controls::SelectionChangedEventArgs& e); void _CommandTimelineSizeChanged(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::SizeChangedEventArgs& e); void _CommandTimelineWheelSettled(const Windows::Foundation::IInspectable& sender, const Windows::Foundation::IInspectable& e); @@ -413,6 +419,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation void _refreshCommandTimeline(); void _renderCommandTimeline(const winTerm::CommandTimeline::CommandTimelinePresentationSnapshot& presentation); void _closeCommandTimeline(bool returnFocus); + void _updateCommandTimelineHandleVisual(); void _coreCommandTimelineChanged(const IInspectable& sender, const IInspectable& args); void _QuickFixButton_PointerEntered(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); diff --git a/src/cascadia/TerminalControl/TermControl.xaml b/src/cascadia/TerminalControl/TermControl.xaml index 25a498913..c369cf851 100644 --- a/src/cascadia/TerminalControl/TermControl.xaml +++ b/src/cascadia/TerminalControl/TermControl.xaml @@ -1429,20 +1429,35 @@ + , BellSound, "bellSound", nullptr) \ X(bool, Elevate, "elevate", false) \ X(bool, AutoMarkPrompts, "autoMarkPrompts", true) \ + X(bool, AutoInjectShellIntegration, "shellIntegration.autoInject", true) \ X(bool, ShowMarks, "showMarksOnScrollbar", false) \ X(bool, RepositionCursorWithMouse, "experimental.repositionCursorWithMouse", false) \ X(bool, ReloadEnvironmentVariables, "compatibility.reloadEnvironmentVariables", true) \ diff --git a/src/cascadia/TerminalSettingsModel/Profile.idl b/src/cascadia/TerminalSettingsModel/Profile.idl index 65de99b91..29fa8c224 100644 --- a/src/cascadia/TerminalSettingsModel/Profile.idl +++ b/src/cascadia/TerminalSettingsModel/Profile.idl @@ -80,6 +80,7 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_PROFILE_SETTING(Boolean, Elevate); INHERITABLE_PROFILE_SETTING(Boolean, AutoMarkPrompts); + INHERITABLE_PROFILE_SETTING(Boolean, AutoInjectShellIntegration); INHERITABLE_PROFILE_SETTING(Boolean, ShowMarks); INHERITABLE_PROFILE_SETTING(Boolean, RightClickContextMenu); diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp index e4f20dbf4..c89478b73 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp @@ -6,6 +6,7 @@ #include #include "../../winterm/Clipboard/PasteRiskAnalyzer.h" +#include "../../winterm/Shell/AutoIntegration.h" #include "../../winterm/Shell/Protocol/ShellIntegrationProtocol.h" #include "../../winterm/Shell/Sessions/ShellSessionMetadata.h" @@ -21,6 +22,8 @@ namespace SettingsModelUnitTests TEST_METHOD(ProtocolClassifierAcceptsKnownSafePayloads); TEST_METHOD(PasteRiskAnalyzerClassifiesWithoutChangingText); + TEST_METHOD(AutoIntegrationRewritesBarePowerShellCommandlines); + TEST_METHOD(AutoIntegrationRefusesCustomizedOrUnsafeInput); }; void WinTermShellTests::ProtocolClassifierAcceptsKnownSafePayloads() @@ -57,4 +60,69 @@ namespace SettingsModelUnitTests VERIFY_IS_TRUE(std::find(analysis.reasons.begin(), analysis.reasons.end(), PasteRiskReason::SuspiciousCommand) != analysis.reasons.end()); VERIFY_ARE_EQUAL(std::wstring{ L"Remove-Item -Recurse -Force .\n" }, content); } + + void WinTermShellTests::AutoIntegrationRewritesBarePowerShellCommandlines() + { + const std::wstring manifest{ L"C:\\Program Files\\winTerm\\ShellAssets\\powershell\\winTerm.Shell\\winTerm.Shell.psd1" }; + const std::wstring sessionId{ L"{01234567-89ab-cdef-0123-456789abcdef}" }; + + const auto stock = BuildAutoIntegratedPowerShellCommandline( + L"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", manifest, sessionId); + VERIFY_IS_TRUE(stock.has_value()); + if (stock) + { + // The original invocation is preserved as the prefix, and the + // appended fragment sets the session marker before the import. + VERIFY_ARE_EQUAL(size_t{ 0 }, stock->find(L"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")); + VERIFY_IS_TRUE(stock->find(L"-NoExit -Command") != std::wstring::npos); + const auto marker = stock->find(L"$env:WINTERM_SESSION_ID='" + sessionId + L"'"); + const auto version = stock->find(L"$env:WINTERM_INTEGRATION_VERSION='1'"); + const auto import = stock->find(L"Import-Module -Name '" + manifest + L"' -ErrorAction SilentlyContinue"); + VERIFY_IS_TRUE(marker != std::wstring::npos); + VERIFY_IS_TRUE(version != std::wstring::npos); + VERIFY_IS_TRUE(import != std::wstring::npos); + VERIFY_IS_TRUE(marker < version && version < import); + } + + const auto quoted = BuildAutoIntegratedPowerShellCommandline( + L"\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -NoLogo", manifest, sessionId); + VERIFY_IS_TRUE(quoted.has_value()); + + const auto bareName = BuildAutoIntegratedPowerShellCommandline(L"pwsh -NoExit", manifest, sessionId); + VERIFY_IS_TRUE(bareName.has_value()); + + // A rewritten commandline reused by a restarted connection is not + // rewritten a second time. + if (stock) + { + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(*stock, manifest, sessionId).has_value()); + } + } + + void WinTermShellTests::AutoIntegrationRefusesCustomizedOrUnsafeInput() + { + const std::wstring manifest{ L"C:\\winTerm\\ShellAssets\\powershell\\winTerm.Shell\\winTerm.Shell.psd1" }; + const std::wstring sessionId{ L"session-1" }; + + // Only PowerShell hosts are recognized, by executable basename. + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"%SystemRoot%\\System32\\cmd.exe", manifest, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"wsl.exe", manifest, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"C:\\tools\\notpowershell.exe", manifest, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"powershell.exe.bat", manifest, sessionId).has_value()); + + // Any argument beyond -NoLogo and -NoExit means a customized + // invocation, which is never rewritten. + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"powershell.exe -Command \"Get-Date\"", manifest, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"powershell.exe -File demo.ps1", manifest, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"powershell.exe -ExecutionPolicy Bypass", manifest, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"pwsh.exe -NoProfile", manifest, sessionId).has_value()); + + // Quotes inside the manifest path or the session id could break out + // of the injected fragment, so both are refused outright. + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"pwsh.exe", L"C:\\odd'path\\winTerm.Shell.psd1", sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"pwsh.exe", manifest, L"bad'id").has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"pwsh.exe", manifest, L"bad id").has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"pwsh.exe", {}, sessionId).has_value()); + VERIFY_IS_FALSE(BuildAutoIntegratedPowerShellCommandline(L"pwsh.exe", manifest, {}).has_value()); + } } diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp index edb25c677..e9f26b13f 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp @@ -41,6 +41,8 @@ namespace SettingsModelUnitTests TEST_METHOD(RecognitionClassifiesAllProvidersOneCodeUnitAtATime); TEST_METHOD(RecognitionRejectsMalformedNumericAndInterruptedOutput); TEST_METHOD(RecognitionPreservesHighConfidenceOwnershipAndClearsGeneric); + TEST_METHOD(RecognitionIgnoresProductMentionsInListings); + TEST_METHOD(RecognitionClearsStaleRunningProviderAfterOrdinaryRecords); TEST_METHOD(RecognitionBootstrapsRichPipAndMavenResolver); TEST_METHOD(RecognitionHandlesGenericIndeterminateShapes); TEST_METHOD(RecognitionHandlesArbitraryProviderSplitsAndReset); @@ -1008,7 +1010,16 @@ namespace SettingsModelUnitTests L"demo.bin 50%[====> ] 512K 1.0MB/s eta 1s\r\x1b[2K", 100, replacement); - VERIFY_IS_FALSE(laterWgetShape.progress.has_value()); + // The stale shape is not reclaimed by wget. As the second consecutive + // record without a matching provider, it structurally clears the + // dangling wget bar instead of leaving it running. + VERIFY_IS_TRUE(laterWgetShape.progress.has_value()); + if (laterWgetShape.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::None), static_cast(laterWgetShape.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(laterWgetShape.progress->mode)); + VERIFY_IS_FALSE(laterWgetShape.progress->visible); + } VERIFY_IS_FALSE(laterWgetShape.suppressInput); RecognitionEngine generic; @@ -1031,6 +1042,75 @@ namespace SettingsModelUnitTests VERIFY_IS_FALSE(cleared.suppressInput); } + void WinTermVisualProgressTests::RecognitionIgnoresProductMentionsInListings() + { + // A directory listing is ordinary output. An entry that mentions a + // build tool by name, or a slashed date column, must not start a bar. + RecognitionEngine listing; + VERIFY_IS_FALSE(listing.Consume(L"d----- 2025/10/13 01:28 .gradle\n", 0).progress.has_value()); + VERIFY_IS_FALSE(listing.Consume(L"d----- 2025/10/13 01:28 Downloads\n", 50).progress.has_value()); + VERIFY_IS_FALSE(listing.Consume(L"-a---- 2025/10/13 01:28 185 notes.ini\n", 100).progress.has_value()); + VERIFY_IS_FALSE(listing.Consume(L"-a---- 01/10/2025 01:28 46 setup.log\n", 150).progress.has_value()); + VERIFY_IS_FALSE(listing.Consume(L"PS C:\\demo> cd \\\n", 200).progress.has_value()); + + // An established claim must carry per-record evidence to rematch; a + // later arbitrary record must not refresh the bar. + RecognitionEngine claimed; + const auto task = claimed.Consume(L"> Task :app:compileJava\n", 0); + VERIFY_IS_TRUE(task.progress.has_value()); + if (task.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Gradle), static_cast(task.progress->provider)); + VERIFY_IS_TRUE(task.progress->visible); + } + VERIFY_IS_FALSE(claimed.Consume(L"PS C:\\demo> dir\n", 50).progress.has_value()); + + // The status meter keeps matching through its own real value. + const auto meter = claimed.Consume(L"<=========----> 75% EXECUTING [16s]\n", 100); + VERIFY_IS_TRUE(meter.progress.has_value()); + if (meter.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Gradle), static_cast(meter.progress->provider)); + VERIFY_ARE_EQUAL(uint8_t{ 75 }, meter.progress->value); + } + } + + void WinTermVisualProgressTests::RecognitionClearsStaleRunningProviderAfterOrdinaryRecords() + { + // A still-running provider bar tolerates one ordinary record, and the + // second consecutive ordinary record publishes a structural clear. + RecognitionEngine stale; + const auto claimed = stale.Consume(L"Downloading https://services.gradle.org/distributions/gradle-8.5-bin.zip\n", 0); + VERIFY_IS_TRUE(claimed.progress.has_value()); + if (claimed.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Gradle), static_cast(claimed.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(claimed.progress->mode)); + VERIFY_IS_TRUE(claimed.progress->visible); + } + VERIFY_IS_FALSE(stale.Consume(L"ordinary command output\n", 50).progress.has_value()); + const auto cleared = stale.Consume(L"PS C:\\demo> dir\n", 100); + VERIFY_IS_TRUE(cleared.progress.has_value()); + if (cleared.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::None), static_cast(cleared.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(cleared.progress->mode)); + VERIFY_IS_FALSE(cleared.progress->visible); + } + + // Success and Error are final results and persist across ordinary + // output until a later publication replaces them. + RecognitionEngine finished; + const auto success = finished.Consume(L"npm completed\n", 0); + VERIFY_IS_TRUE(success.progress.has_value()); + if (success.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Success), static_cast(success.progress->status)); + } + VERIFY_IS_FALSE(finished.Consume(L"ordinary command output\n", 50).progress.has_value()); + VERIFY_IS_FALSE(finished.Consume(L"more ordinary command output\n", 100).progress.has_value()); + } + void WinTermVisualProgressTests::RecognitionBootstrapsRichPipAndMavenResolver() { RecognitionEngine pip; diff --git a/src/winterm/Shell/AutoIntegration.h b/src/winterm/Shell/AutoIntegration.h new file mode 100644 index 000000000..0ac929cc1 --- /dev/null +++ b/src/winterm/Shell/AutoIntegration.h @@ -0,0 +1,200 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include +#include +#include +#include + +namespace winTerm::Shell +{ + // Automatic shell integration rewrites only a bare PowerShell profile + // commandline so the packaged winTerm.Shell module is imported at startup. + // The rules are deliberately narrow: + // + // * Only powershell.exe and pwsh.exe are recognized, by executable + // basename, with or without the extension. + // * The only arguments tolerated on the original commandline are -NoLogo + // and -NoExit. Any other argument means the user has customized the + // invocation, and the commandline is left untouched. In particular a + // -Command, -File, or -EncodedCommand invocation is never rewritten. + // * The module manifest path and the session id are refused when they + // contain a quote character, so the injected fragment cannot be broken + // out of. Execution policy is never altered. + // * A commandline that already mentions the module is left untouched, so + // a restarted connection that reuses a rewritten commandline is not + // rewritten twice. + namespace details + { + inline constexpr wchar_t AsciiLower(const wchar_t value) noexcept + { + return value >= L'A' && value <= L'Z' ? value + (L'a' - L'A') : value; + } + + inline bool EqualsInsensitive(const std::wstring_view left, const std::wstring_view right) noexcept + { + if (left.size() != right.size()) + { + return false; + } + for (size_t i = 0; i < left.size(); ++i) + { + if (AsciiLower(left[i]) != AsciiLower(right[i])) + { + return false; + } + } + return true; + } + + inline bool ContainsInsensitive(const std::wstring_view value, const std::wstring_view needle) noexcept + { + if (needle.empty() || needle.size() > value.size()) + { + return false; + } + for (size_t i = 0; i + needle.size() <= value.size(); ++i) + { + if (EqualsInsensitive(value.substr(i, needle.size()), needle)) + { + return true; + } + } + return false; + } + + // Splits off the next space-delimited token, honoring double quotes. + // Returns an empty view when the input is exhausted. + inline std::wstring_view NextToken(std::wstring_view& remaining) noexcept + { + size_t first{}; + while (first < remaining.size() && (remaining[first] == L' ' || remaining[first] == L'\t')) + { + ++first; + } + if (first == remaining.size()) + { + remaining = {}; + return {}; + } + + auto last = first; + auto quoted = false; + while (last < remaining.size()) + { + const auto ch = remaining[last]; + if (ch == L'"') + { + quoted = !quoted; + } + else if (!quoted && (ch == L' ' || ch == L'\t')) + { + break; + } + ++last; + } + + const auto token = remaining.substr(first, last - first); + remaining.remove_prefix(last); + return token; + } + + inline std::wstring_view StripQuotes(std::wstring_view token) noexcept + { + if (token.size() >= 2 && token.front() == L'"' && token.back() == L'"') + { + token.remove_prefix(1); + token.remove_suffix(1); + } + return token; + } + + inline std::wstring_view ExecutableBasename(std::wstring_view executable) noexcept + { + const auto separator = executable.find_last_of(L"\\/"); + if (separator != std::wstring_view::npos) + { + executable.remove_prefix(separator + 1); + } + return executable; + } + } + + inline bool IsBarePowerShellCommandline(const std::wstring_view commandline) noexcept + { + auto remaining = commandline; + const auto executable = details::StripQuotes(details::NextToken(remaining)); + if (executable.empty()) + { + return false; + } + + const auto basename = details::ExecutableBasename(executable); + static constexpr std::array supported{ + L"powershell.exe", L"pwsh.exe", L"powershell", L"pwsh" + }; + auto recognized = false; + for (const auto candidate : supported) + { + recognized = recognized || details::EqualsInsensitive(basename, candidate); + } + if (!recognized) + { + return false; + } + + static constexpr std::array tolerated{ + L"-nologo", L"-noexit" + }; + for (auto token = details::NextToken(remaining); !token.empty(); token = details::NextToken(remaining)) + { + auto allowed = false; + for (const auto candidate : tolerated) + { + allowed = allowed || details::EqualsInsensitive(token, candidate); + } + if (!allowed) + { + return false; + } + } + return true; + } + + inline std::optional BuildAutoIntegratedPowerShellCommandline(const std::wstring_view commandline, + const std::wstring_view moduleManifestPath, + const std::wstring_view sessionId) + { + if (moduleManifestPath.empty() || sessionId.empty()) + { + return std::nullopt; + } + if (moduleManifestPath.find_first_of(L"'\"") != std::wstring_view::npos || + sessionId.find_first_of(L"'\" \t") != std::wstring_view::npos) + { + return std::nullopt; + } + if (details::ContainsInsensitive(commandline, L"winterm.shell")) + { + return std::nullopt; + } + if (!IsBarePowerShellCommandline(commandline)) + { + return std::nullopt; + } + + std::wstring integrated{ commandline }; + while (!integrated.empty() && (integrated.back() == L' ' || integrated.back() == L'\t')) + { + integrated.pop_back(); + } + integrated.append(L" -NoExit -Command \"&{ $env:WINTERM_SESSION_ID='"); + integrated.append(sessionId); + integrated.append(L"'; $env:WINTERM_INTEGRATION_VERSION='1'; Import-Module -Name '"); + integrated.append(moduleManifestPath); + integrated.append(L"' -ErrorAction SilentlyContinue }\""); + return integrated; + } +} diff --git a/src/winterm/VisualProgress/ProgressRecognition.h b/src/winterm/VisualProgress/ProgressRecognition.h index 969857fb3..5ee467eb0 100644 --- a/src/winterm/VisualProgress/ProgressRecognition.h +++ b/src/winterm/VisualProgress/ProgressRecognition.h @@ -500,6 +500,13 @@ namespace winTerm::VisualProgress { ++rightLast; } + // A chained digit/digit/digit shape is a slashed date or a + // path segment, never a completed/total meter. + if ((leftFirst > 0 && value[leftFirst - 1] == L'/') || + (rightLast < value.size() && value[rightLast] == L'/')) + { + continue; + } uint64_t current{}; uint64_t total{}; if (_parseUnsigned(value, leftFirst, slash, current) && @@ -1366,8 +1373,11 @@ namespace winTerm::VisualProgress Match _matchGradle(const std::wstring_view line) const noexcept { - const auto anchor = _containsInsensitive(line, L"executing") || - _startsWithInsensitive(_trim(line), L"> task") || + const auto taskLine = _startsWithInsensitive(_trim(line), L"> task"); + const auto executingMeter = _containsInsensitive(line, L"executing") && + _realProgress(line).has_value(); + const auto anchor = executingMeter || + taskLine || _containsInsensitive(line, L"gradle") || _containsInsensitive(line, L"build successful") || _containsInsensitive(line, L"build failed"); @@ -1383,9 +1393,29 @@ namespace winTerm::VisualProgress { return { _makeProgress(ProgressProvider::Gradle, ProgressMode::Determinate, ProgressStatus::Success, 100, ProviderConfidence::High, 4), true, true }; } - uint16_t stage = _containsInsensitive(line, L"download") ? 2 : - _startsWithInsensitive(_trim(line), L"> task") ? 3 : - 1; + // Each record must carry build-tool evidence of its own: a status + // meter with a real value, a wrapper download, or a task line. A + // bare product mention, such as a directory listing entry that + // happens to contain the word, must not start or refresh a bar, + // and an established claim must not rematch on arbitrary later + // records. + uint16_t stage{}; + if (_containsInsensitive(line, L"download") && _containsInsensitive(line, L"gradle")) + { + stage = 2; + } + else if (taskLine) + { + stage = 3; + } + else if (executingMeter) + { + stage = 1; + } + if (stage == 0) + { + return {}; + } auto match = _runningMatch(ProgressProvider::Gradle, line, ProviderConfidence::High, stage); match.preserveOnly = true; return match; @@ -1764,6 +1794,7 @@ namespace winTerm::VisualProgress auto match = _recordOverflow || _recordMalformed ? Match{} : _recognize(line, call, transientRecord); if (match.matched) { + _unmatchedRecordStreak = 0; auto& progress = match.progress; const auto preserveOnly = match.preserveOnly || _isPreserveOnly(line); progress.transient = ending == RecordEnding::CarriageReturn || _recordHadEraseLine; @@ -1825,7 +1856,14 @@ namespace winTerm::VisualProgress else { call.onlySafeContent = false; - if (_lastSeen && _lastSeen->visible && _lastSeen->provider == ProgressProvider::Generic) + const auto lastVisible = _lastSeen && _lastSeen->visible; + const auto lastWasTerminal = lastVisible && + (_lastSeen->status == ProgressStatus::Success || + _lastSeen->status == ProgressStatus::Error || + _lastSeen->status == ProgressStatus::Cancelled); + const auto lastWasGeneric = lastVisible && + _lastSeen->provider == ProgressProvider::Generic; + if (lastWasGeneric) { // A structural clear is terminal-state exempt from the // publication throttle and contains no output text. @@ -1833,10 +1871,22 @@ namespace winTerm::VisualProgress } if (!match.pendingCandidate) { + // A progress-shaped record keeps a live claim; only a + // plainly ordinary record advances toward the clear. + _unmatchedRecordStreak = static_cast( + _unmatchedRecordStreak < 2 ? _unmatchedRecordStreak + 1 : 2); + // A built-in provider tolerates one ordinary record so + // an informational line inside a live meter stream does + // not blank the bar, but a still-running bar whose + // stream has moved on must not animate indefinitely. + // Success and Error are final results and remain until + // a later publication replaces them. + if (lastVisible && !lastWasGeneric && !lastWasTerminal && + _unmatchedRecordStreak >= 2) + { + _rememberProgress(_providerClear()); + } _resetGenericHeuristics(); - } - if (!match.pendingCandidate) - { _clearProviderContext(); } } @@ -2146,6 +2196,7 @@ namespace winTerm::VisualProgress // output. The cursor is unknown until rendered output proves it. _columnKnown = false; _atColumnZero = false; + _unmatchedRecordStreak = 0; _clearProviderContext(); _recentProgress = {}; _recentProgressNext = 0; @@ -2186,6 +2237,11 @@ namespace winTerm::VisualProgress uint64_t _genericTransientShapeValue{}; uint16_t _genericTransientShapeLength{}; uint8_t _genericTransientShapeStreak{}; + // Consecutive non-empty records that matched no provider. Survives + // _clearProviderContext so the count can reach the structural-clear + // threshold across the context drop that the first ordinary record + // already performs. + uint8_t _unmatchedRecordStreak{}; std::array _dockerLayers{}; std::array _buildKitSteps{}; std::array _recentProgress{};