diff --git a/CHANGELOG.md b/CHANGELOG.md index ab128ba28..0c70fe5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## Unreleased + +Fixes for the five alpha2 field reports. + +### Fixed + +- Shell integration no longer prints stray `\` characters before every + prompt. The module's string terminator was written as two characters — + in PowerShell single quotes `'\\'` is a literal double backslash — so the + terminal consumed the well-formed sequence and printed the leftover + backslash: three before the first prompt (A, cwd, B) and four after a + command (D as well). +- The Command Timeline now records exactly the typed command. The prompt + marks were written as console side effects while the prompt function ran, + and the host writes a prompt function's console output before its returned + text, so the command-start mark landed before the visible prompt and the + captured "command" included the whole prompt line. The marks are now + embedded in the returned prompt string in FinalTerm order, which also + fixes Load inserting the prompt path into the input line. +- A shell-integrated pane no longer animates the Visual Progress bar while + sitting at an idle prompt. `133;B` means the user is composing input, so + it now hides the shell progress snapshot; only `133;C` (command executed) + starts the running bar. +- The Timeline no longer shows a phantom `Command text unavailable` / + `Running` entry for the active prompt. A `133;B`-only mark is the user + composing input, not a command, and no longer creates an entry; the entry + materializes when the command executes. +- Timeline rows no longer jump when moving the selection with the arrow + keys or hovering with the mouse. Selection-only updates now reuse the + existing rows instead of rebuilding every XAML element. +- A module component blocked by antivirus at parse time (observed for + `Compatibility.ps1` under some engines) no longer spills a parse error + into the session. The component is skipped and recorded in diagnostics; + shell integration and the remaining commands still load. + ## 1.3.0-alpha2 - 2026-08-04 Second alpha prerelease: fixes for the four alpha1 field reports. Like alpha1, diff --git a/docs/powershell-integration.md b/docs/powershell-integration.md index 63a105804..1e618818b 100644 --- a/docs/powershell-integration.md +++ b/docs/powershell-integration.md @@ -39,9 +39,13 @@ 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. +On an eligible session, the module captures the current `prompt` function and wraps it. 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. -The module emits `OSC 133;A`, `OSC 9;9`, `OSC 133;B`, and then `OSC 133;D;` on the next prompt. The inherited `autoMarkPrompts` behavior supplies the command-executed transition. Command duration is intentionally not guessed from prompt idle time. +The wrapper returns a single string with the marks embedded around the original prompt text: `OSC 133;D;` (from the second prompt on), `OSC 133;A`, `OSC 9;9`, the original prompt output, then `OSC 133;B`. Embedding matters: the console host writes a prompt function's console output before it writes the returned text, so side-effect writes would place the command-start mark before the visible prompt and the recorded command region would include the prompt itself. Each sequence is terminated by ESC `\`; in PowerShell single quotes that is `'\'` — one character, since backslash is not an escape character in PowerShell strings. The inherited `autoMarkPrompts` behavior supplies the command-executed transition. Command duration is intentionally not guessed from prompt idle time. + +## Component resilience + +Some antivirus engines block individual script files at parse time. A component file that fails to dot-source is skipped and recorded — `Get-WinTermShellDiagnostics` reports a redacted failure category — instead of spilling a parse error into the user's session. Shell integration needs only the `Private` components; a blocked `Compatibility.ps1` costs the compatibility commands and nothing else. Only functions that actually loaded are exported. ## Compatibility and completion diff --git a/shell/powershell/winTerm.Shell/Private/Prompt.ps1 b/shell/powershell/winTerm.Shell/Private/Prompt.ps1 index 678d9c993..97242f16c 100644 --- a/shell/powershell/winTerm.Shell/Private/Prompt.ps1 +++ b/shell/powershell/winTerm.Shell/Private/Prompt.ps1 @@ -7,13 +7,19 @@ function Invoke-WinTermPrompt param() $lastSuccess = $? + + # The marks are embedded in the returned prompt string rather than written + # as side effects. The console host writes a prompt function's console + # output before it writes the returned text, so a side-effect 133;B would + # land before the visible prompt and the command region would start at the + # prompt text instead of at the user's input. + $prefix = '' if ($script:WinTermHasPrompted) { - Write-WinTermOsc -Payload ('133;D;' + (Get-WinTermExitCode -LastSuccess $lastSuccess)) + $prefix += Get-WinTermOscSequence -Payload ('133;D;' + (Get-WinTermExitCode -LastSuccess $lastSuccess)) } - - Write-WinTermOsc -Payload '133;A' - Send-WinTermCurrentDirectory + $prefix += Get-WinTermOscSequence -Payload '133;A' + $prefix += Get-WinTermCurrentDirectorySequence try { @@ -25,9 +31,9 @@ function Invoke-WinTermPrompt $promptText = 'PS> ' } - Write-WinTermOsc -Payload '133;B' + $suffix = Get-WinTermOscSequence -Payload '133;B' $script:WinTermHasPrompted = $true - return $promptText + return ($prefix + $promptText + $suffix) } function Test-WinTermPromptWrapper diff --git a/shell/powershell/winTerm.Shell/Private/Protocol.ps1 b/shell/powershell/winTerm.Shell/Private/Protocol.ps1 index 5498335ad..e61684ebf 100644 --- a/shell/powershell/winTerm.Shell/Private/Protocol.ps1 +++ b/shell/powershell/winTerm.Shell/Private/Protocol.ps1 @@ -1,7 +1,11 @@ # Copyright (c) winTerm contributors. # Licensed under the MIT license. -function Write-WinTermOsc +# Returns the complete escape sequence for a payload, or an empty string when +# the payload is not eligible. The string terminator is ESC followed by one +# backslash; in PowerShell single quotes a backslash is already literal, so +# '\' is exactly one character. +function Get-WinTermOscSequence { [CmdletBinding()] param( @@ -12,12 +16,29 @@ function Write-WinTermOsc if ($Payload.Length -eq 0 -or $Payload.Length -gt 8192 -or $Payload -match '[\x00-\x1F\x7F]') { $script:WinTermLastIntegrationError = 'An invalid shell integration payload was ignored.' + return '' + } + + return ([char]27).ToString() + ']' + $Payload + [char]27 + '\' +} + +function Write-WinTermOsc +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Payload + ) + + $sequence = Get-WinTermOscSequence -Payload $Payload + if ($sequence.Length -eq 0) + { return } try { - [Console]::Out.Write((([char]27).ToString() + ']' + $Payload + [char]27 + '\\')) + [Console]::Out.Write($sequence) } catch { @@ -25,7 +46,9 @@ function Write-WinTermOsc } } -function Send-WinTermCurrentDirectory +# Returns the current-directory sequence, or an empty string when the current +# location is not an eligible file-system path. +function Get-WinTermCurrentDirectorySequence { [CmdletBinding()] param() @@ -35,20 +58,42 @@ function Send-WinTermCurrentDirectory $location = Get-Location if ($location.Provider.Name -ne 'FileSystem') { - return + return '' } $path = $location.ProviderPath if ([string]::IsNullOrWhiteSpace($path) -or $path -match '[\x00-\x1F\x7F"]') { - return + return '' } - Write-WinTermOsc -Payload ('9;9;"' + $path + '"') + return Get-WinTermOscSequence -Payload ('9;9;"' + $path + '"') } catch { $script:WinTermLastIntegrationError = 'The current directory could not be reported.' + return '' + } +} + +function Send-WinTermCurrentDirectory +{ + [CmdletBinding()] + param() + + $sequence = Get-WinTermCurrentDirectorySequence + if ($sequence.Length -eq 0) + { + return + } + + try + { + [Console]::Out.Write($sequence) + } + catch + { + $script:WinTermLastIntegrationError = 'The terminal did not accept a shell integration sequence.' } } diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 index 60ce7475c..3bce5fffa 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 @@ -14,6 +14,11 @@ $script:WinTermSessionCompatibilityMode = $null $script:WinTermLastIntegrationError = $null $script:WinTermCompletionProvider = 'PowerShell native completion' +# Shell integration must survive a blocked component file. Some antivirus +# engines block individual script files at parse time; a failed dot-source is +# recorded and that component is skipped, instead of spilling a parse error +# into the user's session. Integration itself needs only the Private files. +$script:WinTermFailedComponents = @() foreach ($relativePath in @( 'Private\State.ps1', 'Private\Protocol.ps1', @@ -23,39 +28,75 @@ foreach ($relativePath in @( 'Completion\CompatibilityCompletion.ps1' )) { - . (Join-Path $PSScriptRoot $relativePath) + try + { + . (Join-Path $PSScriptRoot $relativePath) 2>$null + } + catch + { + $script:WinTermFailedComponents += $relativePath + $script:WinTermLastIntegrationError = 'A module component was blocked or failed to load and was skipped.' + } +} + +# Only functions that actually loaded may be exported or invoked, so a +# skipped component degrades that one capability and nothing else. +function Test-WinTermModuleFunction +{ + param( + [Parameter(Mandatory)] + [string]$Name + ) + + return $null -ne (Get-Command -Name $Name -CommandType Function -ErrorAction SilentlyContinue) } $script:WinTermExportedCompatibilityCommands = @() -foreach ($commandName in @('ll', 'la', 'which', 'touch', 'open')) +if (Test-WinTermModuleFunction -Name 'Test-WinTermExistingCommand') { - if (-not (Test-WinTermExistingCommand -Name $commandName)) + foreach ($commandName in @('ll', 'la', 'which', 'touch', 'open')) { - $script:WinTermExportedCompatibilityCommands += $commandName + if ((Test-WinTermModuleFunction -Name $commandName) -and + -not (Test-WinTermExistingCommand -Name $commandName)) + { + $script:WinTermExportedCompatibilityCommands += $commandName + } } } -Register-WinTermCompatibilityCompletion +if (Test-WinTermModuleFunction -Name 'Register-WinTermCompatibilityCompletion') +{ + Register-WinTermCompatibilityCompletion +} -if (Test-WinTermInteractiveSession) +if ((Test-WinTermModuleFunction -Name 'Test-WinTermInteractiveSession') -and + (Test-WinTermModuleFunction -Name 'Enable-WinTermShellIntegration') -and + (Test-WinTermInteractiveSession)) { Enable-WinTermShellIntegration | Out-Null } $ExecutionContext.SessionState.Module.OnRemove = { - Disable-WinTermShellIntegration | Out-Null + if ($null -ne (Get-Command -Name 'Disable-WinTermShellIntegration' -CommandType Function -ErrorAction SilentlyContinue)) + { + Disable-WinTermShellIntegration | Out-Null + } } -Export-ModuleMember -Function @( - 'Get-WinTermShellDiagnostics', - 'Test-WinTermShellIntegration', - 'Enable-WinTermShellIntegration', - 'Disable-WinTermShellIntegration', - 'Get-WinTermCompatibilityMode', - 'Set-WinTermCompatibilityMode' -) -Variable @() - -if ($script:WinTermExportedCompatibilityCommands.Count -gt 0) +$script:WinTermExportedFunctions = @() +foreach ($functionName in @( + 'Get-WinTermShellDiagnostics', + 'Test-WinTermShellIntegration', + 'Enable-WinTermShellIntegration', + 'Disable-WinTermShellIntegration', + 'Get-WinTermCompatibilityMode', + 'Set-WinTermCompatibilityMode' + )) { - Export-ModuleMember -Function $script:WinTermExportedCompatibilityCommands -Variable @() + if (Test-WinTermModuleFunction -Name $functionName) + { + $script:WinTermExportedFunctions += $functionName + } } + +Export-ModuleMember -Function ($script:WinTermExportedFunctions + $script:WinTermExportedCompatibilityCommands) -Variable @() diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 6f1613515..4bd425e75 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -2916,6 +2916,39 @@ namespace winrt::Microsoft::Terminal::Control::implementation } const auto list = CommandTimelineList(); + + // Selection-only updates must not rebuild the rows: recreating every + // XAML element on each arrow key or hover makes the visible items + // jump. When the rendered content is unchanged, only the selection + // moves. + const auto statusPresentationSignature = [](const winTerm::CommandTimeline::ExecutionResult result) noexcept { + return static_cast(L'0' + static_cast(result)); + }; + std::vector signatures; + signatures.reserve(presentation.visibleEntries.size()); + for (size_t slot = 0; slot < presentation.visibleEntries.size(); ++slot) + { + const auto& entry = presentation.visibleEntries[slot]; + auto signature = entry.commandText; + signature.push_back(L'\x1f'); + signature.push_back(statusPresentationSignature(entry.executionResult)); + signature.push_back(L'\x1f'); + signature.append(std::to_wstring(presentation.firstVisibleIndex + slot)); + signature.push_back(L'\x1f'); + signature.append(std::to_wstring(presentation.filteredEntryCount)); + signatures.emplace_back(std::move(signature)); + } + if (!signatures.empty() && + signatures == _commandTimelineRowSignatures && + list.Items().Size() == signatures.size()) + { + _updatingCommandTimelineSelection = true; + list.SelectedIndex(gsl::narrow_cast(presentation.selectedVisualSlot)); + _updatingCommandTimelineSelection = false; + return; + } + _commandTimelineRowSignatures = std::move(signatures); + _updatingCommandTimelineSelection = true; list.SelectedIndex(-1); list.Items().Clear(); @@ -3079,6 +3112,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelineOpen = false; _clearCommandTimelinePendingLoad(); + _commandTimelineRowSignatures.clear(); _updatingCommandTimelineSelection = true; // The query, the filtered projection, and every materialized row are // released together; nothing about a search survives a close. diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index a2a6f112c..545abd090 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -326,6 +326,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool _commandTimelineHandleFocused{ false }; std::array _commandTimelineConsumedKeys{}; std::optional _commandTimelinePendingLoad; + std::vector _commandTimelineRowSignatures; winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellLightAnimation{ nullptr }; winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellDarkAnimation{ nullptr }; diff --git a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp index 3f662afec..204759145 100644 --- a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp +++ b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp @@ -186,12 +186,16 @@ namespace ControlUnitTests void CommandTimelineTests::LifecycleUpdatesAreIncrementalAndIdempotent() { CommandTimelineIndex index{ PaneOne }; + // 133;B alone is the user composing input at the prompt. No command + // exists yet, so nothing may be listed for it. index.ProcessLifecycle(Update(LifecycleEventKind::CommandStart, 11, 1)); const auto revisionAfterStart = index.Revision(); index.ProcessLifecycle(Update(LifecycleEventKind::CommandStart, 11, 1)); - VERIFY_ARE_EQUAL(size_t{ 1 }, index.Entries().size()); + VERIFY_IS_TRUE(index.Entries().empty()); VERIFY_ARE_EQUAL(revisionAfterStart, index.Revision()); + // The entry materializes when the command actually executes, and the + // earlier 133;B still lets it read as Running rather than Unknown. index.ProcessLifecycle(Update(LifecycleEventKind::OutputStart, 11, 2, L"build")); VERIFY_ARE_EQUAL(size_t{ 1 }, index.Entries().size()); VERIFY_ARE_EQUAL(static_cast(CommandLifecycleState::Output), static_cast(index.Entries()[0].lifecycleState)); @@ -712,10 +716,14 @@ namespace ControlUnitTests void CommandTimelineTests::ActionLoadRejectsMissingCommandText() { + // An executed command whose text could not be captured still lists, + // but its text-dependent actions must refuse. CommandTimelineIndex index{ PaneOne }; uint64_t revision = 0; index.ProcessLifecycle(Update(LifecycleEventKind::Prompt, 1, ++revision)); index.ProcessLifecycle(Update(LifecycleEventKind::CommandStart, 1, ++revision)); + index.ProcessLifecycle(Update(LifecycleEventKind::OutputStart, 1, ++revision, L"")); + VERIFY_ARE_EQUAL(size_t{ 1 }, index.Entries().size()); CommandTimelineNavigationModel navigation; CommandTimelineViewState viewState; @@ -936,10 +944,19 @@ namespace ControlUnitTests VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::OutputUnavailable), static_cast(staleJump.status)); // A command that never reached its output stage cannot copy output. + // Such an entry comes from bootstrapping a mid-execution mark; a + // 133;B-only prompt no longer produces an entry at all. CommandTimelineIndex pending{ PaneTwo }; - uint64_t pendingRevision = 0; - pending.ProcessLifecycle(Update(LifecycleEventKind::Prompt, 1, ++pendingRevision)); - pending.ProcessLifecycle(Update(LifecycleEventKind::CommandStart, 1, ++pendingRevision)); + const auto pendingLoader = []() { + return NativeBootstrapSnapshot{ + .nativeRevision = 1, + .marks = { + { .nativeMarkId = 1, .commandText = L"pending", .hasCommand = true, .hasOutput = false }, + }, + }; + }; + pending.Access(1, pendingLoader); + VERIFY_ARE_EQUAL(size_t{ 1 }, pending.Entries().size()); CommandTimelineNavigationModel pendingNavigation; CommandTimelineViewState pendingView; pendingNavigation.Open(pending.Entries(), pendingView, pending.Capability(), 4); diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp index e9f26b13f..45c5c8d3b 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp @@ -1868,7 +1868,12 @@ namespace SettingsModelUnitTests { ProgressStateMachine state; state.SetEnabled(true); - auto snapshot = state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + // Composing input at the prompt is not execution: 133;B must not + // show a bar, so an idle shell-integrated pane stays quiet. + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1).has_value()); + VERIFY_IS_FALSE(state.Current().visible); + + auto snapshot = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(snapshot->mode)); snapshot = state.ApplyShellLifecycle(ShellLifecycleState::CommandFinished, 0); @@ -1877,6 +1882,10 @@ namespace SettingsModelUnitTests snapshot = state.ApplyShellLifecycle(ShellLifecycleState::Prompt, -1); VERIFY_IS_FALSE(snapshot->visible); + + // The next idle prompt keeps the bar hidden even after 133;B. + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1).has_value()); + VERIFY_IS_FALSE(state.Current().visible); } void WinTermVisualProgressTests::EmergencyOverridePrecedesSetting() @@ -1944,7 +1953,7 @@ namespace SettingsModelUnitTests VERIFY_IS_FALSE(reset->visible); VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(reset->mode)); - const auto reused = state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + const auto reused = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); VERIFY_IS_TRUE(reused.has_value()); VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(reused->mode)); } diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.cpp b/src/winterm/CommandTimeline/CommandTimelineModel.cpp index 2a70e29d9..378b4d248 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.cpp +++ b/src/winterm/CommandTimeline/CommandTimelineModel.cpp @@ -838,26 +838,17 @@ namespace winTerm::CommandTimeline } break; case LifecycleEventKind::CommandStart: + // 133;B means the user is composing input at the prompt; no + // command exists yet, so no entry is created and no existing + // entry changes state. The capability bit recorded above still + // lets a later OutputStart distinguish Running from Unknown. + // Only a previous command that never reported completion is + // closed out here. if (_currentSequence.has_value() && (!entry || *_currentSequence != entry->id.sequence)) { _finishIncompleteCurrent(update.timestamp); changed = true; } - if (!entry) - { - entry = &_createEntry(update.nativeMarkId, update.timestamp); - changed = true; - } - _currentSequence = entry->id.sequence; - if (entry->lifecycleState != CommandLifecycleState::Command || - entry->executionResult != ExecutionResult::Running) - { - entry->lifecycleState = CommandLifecycleState::Command; - entry->executionResult = ExecutionResult::Running; - entry->trustedExitCode.reset(); - entry->endTimestamp.reset(); - changed = true; - } break; case LifecycleEventKind::OutputStart: { diff --git a/src/winterm/VisualProgress/VisualProgressModel.h b/src/winterm/VisualProgress/VisualProgressModel.h index 896f87b2f..0079ad430 100644 --- a/src/winterm/VisualProgress/VisualProgressModel.h +++ b/src/winterm/VisualProgress/VisualProgressModel.h @@ -272,6 +272,11 @@ namespace winTerm::VisualProgress _shellSnapshot.reset(); break; case ShellLifecycleState::CommandStart: + // OSC 133;B: the user is composing input at an interactive + // prompt. Nothing is executing, so an idle prompt must never + // animate a bar; only CommandExecuted starts one. + _shellSnapshot.reset(); + break; case ShellLifecycleState::CommandExecuted: _shellSnapshot = ProgressSnapshot{ ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::ShellIntegration, 0 }; break;