Skip to content

Commit 6d6bc34

Browse files
committed
Use a provided shell integration script directly
Instead of having to maintain an edited copy (which was really annoying) I stubbed out `PSConsoleHostReadLine` to do what's expected. So now we can just use the existing shell integration script directly! Since we can't reliably find the script using `code --locate-shell-integration-path pwsh` we now rely on it being sent by the client on initialization. Its presence implies the feature is on. This is pretty VS Code specific, but not necessarily so.
1 parent 20f90a9 commit 6d6bc34

File tree

3 files changed

+49
-214
lines changed

3 files changed

+49
-214
lines changed

src/PowerShellEditorServices/Server/PsesLanguageServer.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,9 @@ public async Task StartAsync()
153153
InitialWorkingDirectory = initializationOptions?.GetValue("initialWorkingDirectory")?.Value<string>()
154154
?? workspaceService.WorkspaceFolders.FirstOrDefault()?.Uri.GetFileSystemPath()
155155
?? Directory.GetCurrentDirectory(),
156-
ShellIntegrationEnabled = initializationOptions?.GetValue("shellIntegrationEnabled")?.Value<bool>()
157-
?? false
156+
// If a shell integration script path is provided, that implies the feature is enabled.
157+
ShellIntegrationScript = initializationOptions?.GetValue("shellIntegrationScript")?.Value<string>()
158+
?? "",
158159
};
159160

160161
workspaceService.InitialWorkingDirectory = hostStartOptions.InitialWorkingDirectory;

src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ internal struct HostStartOptions
99

1010
public string InitialWorkingDirectory { get; set; }
1111

12-
public bool ShellIntegrationEnabled { get; set; }
12+
public string ShellIntegrationScript { get; set; }
1313
}
1414
}

src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs

+45-211
Original file line numberDiff line numberDiff line change
@@ -315,16 +315,28 @@ public async Task<bool> TryStartAsync(HostStartOptions startOptions, Cancellatio
315315
_logger.LogDebug("Profiles loaded!");
316316
}
317317

318-
if (startOptions.ShellIntegrationEnabled)
318+
if (!string.IsNullOrEmpty(startOptions.ShellIntegrationScript))
319319
{
320-
_logger.LogDebug("Enabling shell integration...");
320+
_logger.LogDebug("Enabling Terminal Shell Integration...");
321321
_shellIntegrationEnabled = true;
322-
await EnableShellIntegrationAsync(cancellationToken).ConfigureAwait(false);
322+
// TODO: Make the __psEditorServices prefix shared (it's used elsewhere too).
323+
string setupShellIntegration = $$"""
324+
# Setup Terminal Shell Integration.
325+
326+
# Define a fake PSConsoleHostReadLine so the integration script's wrapper
327+
# can execute it to get the user's input.
328+
$global:__psEditorServices_userInput = "";
329+
function global:PSConsoleHostReadLine { $global:__psEditorServices_userInput }
330+
331+
# Execute the provided shell integration script.
332+
try { . "{{startOptions.ShellIntegrationScript}}" } catch {}
333+
""";
334+
await EnableShellIntegrationAsync(setupShellIntegration, cancellationToken).ConfigureAwait(false);
323335
_logger.LogDebug("Shell integration enabled!");
324336
}
325337
else
326338
{
327-
_logger.LogDebug("Shell integration not enabled!");
339+
_logger.LogDebug("Terminal Shell Integration not enabled!");
328340
}
329341

330342
await _started.Task.ConfigureAwait(false);
@@ -495,6 +507,7 @@ public Task ExecuteDelegateAsync(
495507
new SynchronousDelegateTask(_logger, representation, executionOptions, action, cancellationToken));
496508
}
497509

510+
// TODO: One day fix these so the cancellation token is last.
498511
public Task<IReadOnlyList<TResult>> ExecutePSCommandAsync<TResult>(
499512
PSCommand psCommand,
500513
CancellationToken cancellationToken,
@@ -581,209 +594,12 @@ internal Task LoadHostProfilesAsync(CancellationToken cancellationToken)
581594
cancellationToken);
582595
}
583596

584-
private Task EnableShellIntegrationAsync(CancellationToken cancellationToken)
597+
private Task EnableShellIntegrationAsync(string shellIntegrationScript, CancellationToken cancellationToken)
585598
{
586-
// Imported on 01/03/24 from
587-
// https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1
588-
// with quotes escaped, `__VSCodeOriginalPSConsoleHostReadLine` removed (as it's done
589-
// in our own ReadLine function), and `[Console]::Write` replaced with `Write-Host`.
590-
const string shellIntegrationScript = @"
591-
# Prevent installing more than once per session
592-
if (Test-Path variable:global:__VSCodeOriginalPrompt) {
593-
return;
594-
}
595-
596-
# Disable shell integration when the language mode is restricted
597-
if ($ExecutionContext.SessionState.LanguageMode -ne ""FullLanguage"") {
598-
return;
599-
}
600-
601-
$Global:__VSCodeOriginalPrompt = $function:Prompt
602-
603-
$Global:__LastHistoryId = -1
604-
605-
# Store the nonce in script scope and unset the global
606-
$Nonce = $env:VSCODE_NONCE
607-
$env:VSCODE_NONCE = $null
608-
609-
if ($env:VSCODE_ENV_REPLACE) {
610-
$Split = $env:VSCODE_ENV_REPLACE.Split("":"")
611-
foreach ($Item in $Split) {
612-
$Inner = $Item.Split('=')
613-
[Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':'))
614-
}
615-
$env:VSCODE_ENV_REPLACE = $null
616-
}
617-
if ($env:VSCODE_ENV_PREPEND) {
618-
$Split = $env:VSCODE_ENV_PREPEND.Split("":"")
619-
foreach ($Item in $Split) {
620-
$Inner = $Item.Split('=')
621-
[Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':') + [Environment]::GetEnvironmentVariable($Inner[0]))
622-
}
623-
$env:VSCODE_ENV_PREPEND = $null
624-
}
625-
if ($env:VSCODE_ENV_APPEND) {
626-
$Split = $env:VSCODE_ENV_APPEND.Split("":"")
627-
foreach ($Item in $Split) {
628-
$Inner = $Item.Split('=')
629-
[Environment]::SetEnvironmentVariable($Inner[0], [Environment]::GetEnvironmentVariable($Inner[0]) + $Inner[1].Replace('\x3a', ':'))
630-
}
631-
$env:VSCODE_ENV_APPEND = $null
632-
}
633-
634-
function Global:__VSCode-Escape-Value([string]$value) {
635-
# NOTE: In PowerShell v6.1+, this can be written `$value -replace '…', { … }` instead of `[regex]::Replace`.
636-
# Replace any non-alphanumeric characters.
637-
[regex]::Replace($value, '[\\\n;]', { param($match)
638-
# Encode the (ascii) matches as `\x<hex>`
639-
-Join (
640-
[System.Text.Encoding]::UTF8.GetBytes($match.Value) | ForEach-Object { '\x{0:x2}' -f $_ }
641-
)
642-
})
643-
}
644-
645-
function Global:Prompt() {
646-
$FakeCode = [int]!$global:?
647-
# NOTE: We disable strict mode for the scope of this function because it unhelpfully throws an
648-
# error when $LastHistoryEntry is null, and is not otherwise useful.
649-
Set-StrictMode -Off
650-
$LastHistoryEntry = Get-History -Count 1
651-
# Skip finishing the command if the first command has not yet started
652-
if ($Global:__LastHistoryId -ne -1) {
653-
if ($LastHistoryEntry.Id -eq $Global:__LastHistoryId) {
654-
# Don't provide a command line or exit code if there was no history entry (eg. ctrl+c, enter on no command)
655-
$Result = ""$([char]0x1b)]633;E`a""
656-
$Result += ""$([char]0x1b)]633;D`a""
657-
}
658-
else {
659-
# Command finished command line
660-
# OSC 633 ; E ; <CommandLine?> ; <Nonce?> ST
661-
$Result = ""$([char]0x1b)]633;E;""
662-
# Sanitize the command line to ensure it can get transferred to the terminal and can be parsed
663-
# correctly. This isn't entirely safe but good for most cases, it's important for the Pt parameter
664-
# to only be composed of _printable_ characters as per the spec.
665-
if ($LastHistoryEntry.CommandLine) {
666-
$CommandLine = $LastHistoryEntry.CommandLine
667-
}
668-
else {
669-
$CommandLine = """"
670-
}
671-
$Result += $(__VSCode-Escape-Value $CommandLine)
672-
$Result += "";$Nonce""
673-
$Result += ""`a""
674-
# Command finished exit code
675-
# OSC 633 ; D [; <ExitCode>] ST
676-
$Result += ""$([char]0x1b)]633;D;$FakeCode`a""
677-
}
678-
}
679-
# Prompt started
680-
# OSC 633 ; A ST
681-
$Result += ""$([char]0x1b)]633;A`a""
682-
# Current working directory
683-
# OSC 633 ; <Property>=<Value> ST
684-
$Result += if ($pwd.Provider.Name -eq 'FileSystem') { ""$([char]0x1b)]633;P;Cwd=$(__VSCode-Escape-Value $pwd.ProviderPath)`a"" }
685-
# Before running the original prompt, put $? back to what it was:
686-
if ($FakeCode -ne 0) {
687-
Write-Error ""failure"" -ea ignore
688-
}
689-
# Run the original prompt
690-
$Result += $Global:__VSCodeOriginalPrompt.Invoke()
691-
# Write command started
692-
$Result += ""$([char]0x1b)]633;B`a""
693-
$Global:__LastHistoryId = $LastHistoryEntry.Id
694-
return $Result
695-
}
696-
697-
# Set IsWindows property
698-
if ($PSVersionTable.PSVersion -lt ""6.0"") {
699-
# Windows PowerShell is only available on Windows
700-
Write-Host -NoNewLine ""$([char]0x1b)]633;P;IsWindows=$true`a""
701-
}
702-
else {
703-
Write-Host -NoNewLine ""$([char]0x1b)]633;P;IsWindows=$IsWindows`a""
704-
}
705-
706-
# Set always on key handlers which map to default VS Code keybindings
707-
function Set-MappedKeyHandler {
708-
param ([string[]] $Chord, [string[]]$Sequence)
709-
try {
710-
$Handler = Get-PSReadLineKeyHandler -Chord $Chord | Select-Object -First 1
711-
}
712-
catch [System.Management.Automation.ParameterBindingException] {
713-
# PowerShell 5.1 ships with PSReadLine 2.0.0 which does not have -Chord,
714-
# so we check what's bound and filter it.
715-
$Handler = Get-PSReadLineKeyHandler -Bound | Where-Object -FilterScript { $_.Key -eq $Chord } | Select-Object -First 1
716-
}
717-
if ($Handler) {
718-
Set-PSReadLineKeyHandler -Chord $Sequence -Function $Handler.Function
719-
}
720-
}
721-
722-
$Global:__VSCodeHaltCompletions = $false
723-
function Set-MappedKeyHandlers {
724-
Set-MappedKeyHandler -Chord Ctrl+Spacebar -Sequence 'F12,a'
725-
Set-MappedKeyHandler -Chord Alt+Spacebar -Sequence 'F12,b'
726-
Set-MappedKeyHandler -Chord Shift+Enter -Sequence 'F12,c'
727-
Set-MappedKeyHandler -Chord Shift+End -Sequence 'F12,d'
728-
729-
# Conditionally enable suggestions
730-
if ($env:VSCODE_SUGGEST -eq '1') {
731-
Remove-Item Env:VSCODE_SUGGEST
732-
733-
# VS Code send completions request (may override Ctrl+Spacebar)
734-
Set-PSReadLineKeyHandler -Chord 'F12,e' -ScriptBlock {
735-
Send-Completions
736-
}
737-
738-
# Suggest trigger characters
739-
Set-PSReadLineKeyHandler -Chord ""-"" -ScriptBlock {
740-
[Microsoft.PowerShell.PSConsoleReadLine]::Insert(""-"")
741-
if (!$Global:__VSCodeHaltCompletions) {
742-
Send-Completions
743-
}
744-
}
745-
746-
Set-PSReadLineKeyHandler -Chord 'F12,y' -ScriptBlock {
747-
$Global:__VSCodeHaltCompletions = $true
748-
}
749-
750-
Set-PSReadLineKeyHandler -Chord 'F12,z' -ScriptBlock {
751-
$Global:__VSCodeHaltCompletions = $false
752-
}
753-
}
754-
}
755-
756-
function Send-Completions {
757-
$commandLine = """"
758-
$cursorIndex = 0
759-
# TODO: Since fuzzy matching exists, should completions be provided only for character after the
760-
# last space and then filter on the client side? That would let you trigger ctrl+space
761-
# anywhere on a word and have full completions available
762-
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$cursorIndex)
763-
$completionPrefix = $commandLine
764-
765-
# Get completions
766-
$result = ""`e]633;Completions""
767-
if ($completionPrefix.Length -gt 0) {
768-
# Get and send completions
769-
$completions = TabExpansion2 -inputScript $completionPrefix -cursorColumn $cursorIndex
770-
if ($null -ne $completions.CompletionMatches) {
771-
$result += "";$($completions.ReplacementIndex);$($completions.ReplacementLength);$($cursorIndex);""
772-
$result += $completions.CompletionMatches | ConvertTo-Json -Compress
773-
}
774-
}
775-
$result += ""`a""
776-
777-
Write-Host -NoNewLine $result
778-
}
779-
780-
# Register key handlers if PSReadLine is available
781-
if (Get-Module -Name PSReadLine) {
782-
Set-MappedKeyHandlers
783-
}
784-
";
785-
786-
return ExecutePSCommandAsync(new PSCommand().AddScript(shellIntegrationScript), cancellationToken);
599+
return ExecutePSCommandAsync(
600+
new PSCommand().AddScript(shellIntegrationScript),
601+
cancellationToken,
602+
new PowerShellExecutionOptions { AddToHistory = false, ThrowOnError = false });
787603
}
788604

789605
public Task SetInitialWorkingDirectoryAsync(string path, CancellationToken cancellationToken)
@@ -1262,16 +1078,34 @@ private void InvokeInput(string input, CancellationToken cancellationToken)
12621078

12631079
try
12641080
{
1265-
// For VS Code's shell integration feature, this replaces their
1266-
// PSConsoleHostReadLine function wrapper, as that global function is not available
1267-
// to users of PSES, since we already wrap ReadLine ourselves.
1081+
// For the terminal shell integration feature, we call PSConsoleHostReadLine specially as it's been wrapped.
1082+
// Normally it would not be available (since we wrap ReadLine ourselves),
1083+
// but in this case we've made the original just emit the user's input so that the wrapper works as intended.
12681084
if (_shellIntegrationEnabled)
12691085
{
1270-
System.Console.Write("\x1b]633;C\a");
1086+
// Save the user's input to our special global variable so PSConsoleHostReadLine can read it.
1087+
InvokePSCommand(
1088+
new PSCommand().AddScript($"$global:__psEditorServices_userInput = \"{input}\""),
1089+
new PowerShellExecutionOptions { ThrowOnError = false, WriteOutputToHost = false },
1090+
cancellationToken);
1091+
1092+
// Invoke the PSConsoleHostReadLine wrapper. We don't write the output because it
1093+
// returns the command line (user input) which would then be duplicate noise. Fortunately
1094+
// it writes the shell integration sequences directly using [Console]::Write.
1095+
InvokePSCommand(
1096+
new PSCommand().AddScript("PSConsoleHostReadLine"),
1097+
new PowerShellExecutionOptions { ThrowOnError = false, WriteOutputToHost = false },
1098+
cancellationToken);
1099+
1100+
// Reset our global variable.
1101+
InvokePSCommand(
1102+
new PSCommand().AddScript("$global:__psEditorServices_userInput = \"\""),
1103+
new PowerShellExecutionOptions { ThrowOnError = false, WriteOutputToHost = false },
1104+
cancellationToken);
12711105
}
12721106

12731107
InvokePSCommand(
1274-
new PSCommand().AddScript(input, useLocalScope: false),
1108+
new PSCommand().AddScript(input),
12751109
new PowerShellExecutionOptions
12761110
{
12771111
AddToHistory = true,

0 commit comments

Comments
 (0)