From 09bd1b5f19f05a9e5ae8bf41453e25feba62dd98 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Tue, 4 Aug 2026 15:31:45 +0800 Subject: [PATCH] fix: address the five alpha2 field reports Local testing of v1.3.0-alpha2 reported five issues. Four share one root area: the first real-terminal run of the shell integration module. 1. Stray backslashes before every prompt. Write-WinTermOsc terminated each sequence with ([char]27 + '\\'); in PowerShell single quotes '\\' is a literal two-character string, so every emission was ESC ] payload ESC backslash backslash. 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 terminator is now '\', one character, built in Get-WinTermOscSequence. 2. The Timeline recorded the whole prompt line and Load inserted the prompt path into the input. The marks were written as console side effects while the prompt function ran, and the console host writes a prompt function's console output before its returned text, so 133;B landed before the visible prompt and the command region began at the prompt text. Invoke-WinTermPrompt now returns one string with the marks embedded in FinalTerm order: D (from the second prompt on), A, the cwd report, the original prompt text, then B. 3. The Visual Progress bar animated forever on an idle shell-integrated prompt, even without typing. ProgressStateMachine mapped CommandStart (133;B, the user composing input) to a visible indeterminate bar. B now hides the shell snapshot; only CommandExecuted (133;C) starts the running bar, and CommandFinished and the next prompt behave as before. 4. A phantom "Command text unavailable / Running" entry appeared for the active prompt. The lifecycle model created an entry at CommandStart. A 133;B-only mark is the user composing input, not a command, so B is now bookkeeping only: it closes out a previous command that never reported completion, and the entry materializes at OutputStart or CommandFinished. The capability bit recorded at B still lets the entry read Running rather than Unknown. 5. Timeline rows jumped when moving the selection with the arrow keys or hovering. _renderCommandTimeline rebuilt every ListView row on each selection change. A per-row content signature now detects selection-only updates and moves SelectedIndex without rebuilding. Additionally, a module component blocked by antivirus at parse time (observed for Compatibility.ps1 under some engines, printing a parse error at session start) is now skipped silently and recorded in diagnostics. Only functions that actually loaded are exported, so a blocked component degrades that one capability and nothing else. Tests updated for the new semantics: an executed command with empty text still refuses text actions, the no-output CopyOutput refusal now uses a bootstrapped mid-execution mark, idle 133;B asserts no bar and no entry, and the split-reset rehydration uses CommandExecuted. Verified against a live PowerShell session: the returned prompt is byte-exact FinalTerm order with single-character terminators. 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 | 35 +++++++++ docs/powershell-integration.md | 8 +- .../winTerm.Shell/Private/Prompt.ps1 | 18 +++-- .../winTerm.Shell/Private/Protocol.ps1 | 57 ++++++++++++-- .../winTerm.Shell/winTerm.Shell.psm1 | 77 ++++++++++++++----- src/cascadia/TerminalControl/TermControl.cpp | 34 ++++++++ src/cascadia/TerminalControl/TermControl.h | 1 + .../CommandTimelineTests.cpp | 25 +++++- .../WinTermVisualProgressTests.cpp | 13 +++- .../CommandTimeline/CommandTimelineModel.cpp | 21 ++--- .../VisualProgress/VisualProgressModel.h | 5 ++ 11 files changed, 241 insertions(+), 53 deletions(-) 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;