Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
8 changes: 6 additions & 2 deletions docs/powershell-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;<exit>` 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;<exit>` (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

Expand Down
18 changes: 12 additions & 6 deletions shell/powershell/winTerm.Shell/Private/Prompt.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand Down
57 changes: 51 additions & 6 deletions shell/powershell/winTerm.Shell/Private/Protocol.ps1
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -12,20 +16,39 @@ 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
{
$script:WinTermLastIntegrationError = 'The terminal did not accept a shell integration sequence.'
}
}

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()
Expand All @@ -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.'
}
}

Expand Down
77 changes: 59 additions & 18 deletions shell/powershell/winTerm.Shell/winTerm.Shell.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 @()
34 changes: 34 additions & 0 deletions src/cascadia/TerminalControl/TermControl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<wchar_t>(L'0' + static_cast<int>(result));
};
std::vector<std::wstring> 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<int>(presentation.selectedVisualSlot));
_updatingCommandTimelineSelection = false;
return;
}
_commandTimelineRowSignatures = std::move(signatures);

_updatingCommandTimelineSelection = true;
list.SelectedIndex(-1);
list.Items().Clear();
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/cascadia/TerminalControl/TermControl.h
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
bool _commandTimelineHandleFocused{ false };
std::array<bool, 256> _commandTimelineConsumedKeys{};
std::optional<winTerm::CommandTimeline::CommandActionRequest> _commandTimelinePendingLoad;
std::vector<std::wstring> _commandTimelineRowSignatures;

winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellLightAnimation{ nullptr };
winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellDarkAnimation{ nullptr };
Expand Down
Loading