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{};