Skip to content

Commit 5b30599

Browse files
andyleejordanSeeminglyScience
andcommittedApr 3, 2024·
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. Apply suggestions from code review Thanks Patrick! Co-authored-by: Patrick Meinecke <SeeminglyScience@users.noreply.github.com>
1 parent 20f90a9 commit 5b30599

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 = $args[0]").AddArgument(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)
Please sign in to comment.