Skip to content

Commit f85e2f8

Browse files
committed
Use VS Code's built-in shell integration script
Instead of having to maintain an edited copy (which was really annoying) I stubbed out `PSConsoleHostReadLine` to do what they expect. So now we can just use their script directly!
1 parent 20f90a9 commit f85e2f8

File tree

1 file changed

+46
-211
lines changed

1 file changed

+46
-211
lines changed

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

+46-211
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ internal class PsesInternalHost : PSHost, IHostSupportsInteractiveSession, IRuns
3535
{
3636
internal const string DefaultPrompt = "> ";
3737

38+
// TODO: Make the __psEditorServices prefix shared (it's used elsewhere too).
39+
private const string ShellIntegrationScript = @"
40+
# Define fake PSConsoleHostReadLine so VS Code's script can execute it to get the user's input.
41+
$global:__psEditorServices_userInput = """";
42+
function global:PSConsoleHostReadLine { $global:__psEditorServices_userInput }
43+
44+
# Execute VS Code's built-in shell integration script.
45+
try { . ""$(code --locate-shell-integration-path pwsh)"" } catch {}
46+
";
47+
3848
private static readonly PSCommand s_promptCommand = new PSCommand().AddCommand("prompt");
3949

4050
private static readonly PropertyInfo s_scriptDebuggerTriggerObjectProperty;
@@ -317,14 +327,14 @@ public async Task<bool> TryStartAsync(HostStartOptions startOptions, Cancellatio
317327

318328
if (startOptions.ShellIntegrationEnabled)
319329
{
320-
_logger.LogDebug("Enabling shell integration...");
330+
_logger.LogDebug("Enabling VS Code's shell integration...");
321331
_shellIntegrationEnabled = true;
322332
await EnableShellIntegrationAsync(cancellationToken).ConfigureAwait(false);
323333
_logger.LogDebug("Shell integration enabled!");
324334
}
325335
else
326336
{
327-
_logger.LogDebug("Shell integration not enabled!");
337+
_logger.LogDebug("VS Code's shell integration not enabled!");
328338
}
329339

330340
await _started.Task.ConfigureAwait(false);
@@ -495,6 +505,7 @@ public Task ExecuteDelegateAsync(
495505
new SynchronousDelegateTask(_logger, representation, executionOptions, action, cancellationToken));
496506
}
497507

508+
// TODO: One day fix these so the cancellation token is last.
498509
public Task<IReadOnlyList<TResult>> ExecutePSCommandAsync<TResult>(
499510
PSCommand psCommand,
500511
CancellationToken cancellationToken,
@@ -581,210 +592,14 @@ internal Task LoadHostProfilesAsync(CancellationToken cancellationToken)
581592
cancellationToken);
582593
}
583594

584-
private Task EnableShellIntegrationAsync(CancellationToken cancellationToken)
585-
{
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);
787-
}
595+
private Task EnableShellIntegrationAsync(CancellationToken cancellationToken) => ExecutePSCommandAsync(
596+
new PSCommand().AddScript(ShellIntegrationScript),
597+
cancellationToken,
598+
new PowerShellExecutionOptions
599+
{
600+
AddToHistory = true, // VS Code adds theirs to the history, so we do too.
601+
ThrowOnError = false
602+
});
788603

789604
public Task SetInitialWorkingDirectoryAsync(string path, CancellationToken cancellationToken)
790605
{
@@ -1262,16 +1077,36 @@ private void InvokeInput(string input, CancellationToken cancellationToken)
12621077

12631078
try
12641079
{
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.
1080+
// For VS Code's shell integration feature, we call PSConsoleHostReadLine specially as they've wrapped it.
1081+
// Normally it would not be available (since we wrap ReadLine ourselves),
1082+
// but in this case we've made the original just emit the user's input so that their wrapper works as intended.
12681083
if (_shellIntegrationEnabled)
12691084
{
1270-
System.Console.Write("\x1b]633;C\a");
1085+
// Save the user's input to our special global variable so PSConsoleHostReadLine can read it.
1086+
InvokePSCommand(
1087+
new PSCommand().AddScript($"$global:__psEditorServices_userInput = \"${input}\""),
1088+
new PowerShellExecutionOptions { ThrowOnError = false },
1089+
cancellationToken);
1090+
1091+
// Invoke VS Code's PSConsoleHostReadLine wrapper.
1092+
InvokePSCommand(
1093+
new PSCommand().AddScript("PSConsoleHostReadLine"),
1094+
new PowerShellExecutionOptions
1095+
{
1096+
ThrowOnError = false,
1097+
WriteOutputToHost = true,
1098+
},
1099+
cancellationToken);
1100+
1101+
// Reset our global variable.
1102+
InvokePSCommand(
1103+
new PSCommand().AddScript("$global:__psEditorServices_userInput = \"\""),
1104+
new PowerShellExecutionOptions { ThrowOnError = false },
1105+
cancellationToken);
12711106
}
12721107

12731108
InvokePSCommand(
1274-
new PSCommand().AddScript(input, useLocalScope: false),
1109+
new PSCommand().AddScript(input),
12751110
new PowerShellExecutionOptions
12761111
{
12771112
AddToHistory = true,

0 commit comments

Comments
 (0)