Merge pull request #211477 from microsoft/tyriar/pwsh_si

Change method used for fetching pwsh completions
This commit is contained in:
Daniel Imms
2024-04-27 11:47:47 -07:00
committed by GitHub
6 changed files with 117 additions and 59 deletions
@@ -7,7 +7,7 @@ import { IPtyHostProcessReplayEvent } from 'vs/platform/terminal/common/capabili
import { ReplayEntry } from 'vs/platform/terminal/common/terminalProcess';
const enum Constants {
MaxRecorderDataSize = 1024 * 1024 // 1MB
MaxRecorderDataSize = 10 * 1024 * 1024 // 10MB
}
interface RecorderEntry {
@@ -157,7 +157,6 @@ function Set-MappedKeyHandler {
}
}
$Global:__VSCodeHaltCompletions = $false
function Set-MappedKeyHandlers {
Set-MappedKeyHandler -Chord Ctrl+Spacebar -Sequence 'F12,a'
Set-MappedKeyHandler -Chord Alt+Spacebar -Sequence 'F12,b'
@@ -173,43 +172,51 @@ function Set-MappedKeyHandlers {
Send-Completions
}
# Suggest trigger characters
Set-PSReadLineKeyHandler -Chord "-" -ScriptBlock {
[Microsoft.PowerShell.PSConsoleReadLine]::Insert("-")
if (!$Global:__VSCodeHaltCompletions) {
Send-Completions
}
}
Set-PSReadLineKeyHandler -Chord 'F12,y' -ScriptBlock {
$Global:__VSCodeHaltCompletions = $true
}
Set-PSReadLineKeyHandler -Chord 'F12,z' -ScriptBlock {
$Global:__VSCodeHaltCompletions = $false
}
# TODO: When does this invalidate? Installing a new module could add new commands. We could expose a command to update? Track `(Get-Module).Count`?
# Commands are expensive to complete and send over, do this once for the empty string so we
# don't need to do it each time the user requests. Additionally we also want to do filtering
# and ranking on the client side with the full list of results.
$result = "$([char]0x1b)]633;CompletionsPwshCommands;commands;"
$result += [System.Management.Automation.CompletionCompleters]::CompleteCommand('') | ConvertTo-Json -Compress
Write-Host -NoNewLine $result
}
}
function Send-Completions {
$commandLine = ""
$cursorIndex = 0
# TODO: Since fuzzy matching exists, should completions be provided only for character after the
# last space and then filter on the client side? That would let you trigger ctrl+space
# anywhere on a word and have full completions available
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$cursorIndex)
$completionPrefix = $commandLine
# Get completions
# Start completions sequence
$result = "$([char]0x1b)]633;Completions"
if ($completionPrefix.Length -gt 0) {
# Get and send completions
# If there is a space in the input, defer to TabExpansion2 as it's more complicated to
# determine what type of completions to use
if ($completionPrefix.Contains(' ')) {
$completions = TabExpansion2 -inputScript $completionPrefix -cursorColumn $cursorIndex
if ($null -ne $completions.CompletionMatches) {
$result += ";$($completions.ReplacementIndex);$($completions.ReplacementLength);$($cursorIndex);"
$result += $completions.CompletionMatches | ConvertTo-Json -Compress
}
}
# If there is no space, get completions using CompletionCompleters as it gives us more
# control and works on the empty string
else {
# Note that CompleteCommand isn't included here as it's expensive
$completions = $(
([System.Management.Automation.CompletionCompleters]::CompleteFilename($completionPrefix));
([System.Management.Automation.CompletionCompleters]::CompleteVariable($completionPrefix));
)
if ($null -ne $completions) {
$result += ";$($completions.ReplacementIndex);$($completions.ReplacementLength);$($cursorIndex);"
$result += $completions | ConvertTo-Json -Compress
} else {
$result += ";0;$($completionPrefix.Length);$($completionPrefix.Length);[]"
}
}
# End completions sequence
$result += "`a"
Write-Host -NoNewLine $result
@@ -1452,6 +1452,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
}
}
});
if (this.isDisposed) {
return;
}
if (this.xterm?.shellIntegration) {
this.capabilities.add(this.xterm.shellIntegration.capabilities);
}
@@ -202,6 +202,7 @@ export interface ITerminalConfiguration {
shellIntegration?: {
enabled: boolean;
decorationsEnabled: boolean;
suggestEnabled: boolean;
};
enableImages: boolean;
smoothScrolling: boolean;
@@ -115,6 +115,9 @@ export class TerminalStickyScrollOverlay extends Disposable {
}));
this._getSerializeAddonConstructor().then(SerializeAddon => {
if (this._store.isDisposed) {
return;
}
this._serializeAddon = this._register(new SerializeAddon());
this._xterm.raw.loadAddon(this._serializeAddon);
// Trigger a render as the serialize addon is required to render
@@ -16,16 +16,19 @@ import { IContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { activeContrastBorder } from 'vs/platform/theme/common/colorRegistry';
import { ISuggestController } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ISuggestController, ITerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminal';
import { TerminalStorageKeys } from 'vs/workbench/contrib/terminal/common/terminalStorageKeys';
import type { ITerminalAddon, Terminal } from '@xterm/xterm';
import { getListStyles } from 'vs/platform/theme/browser/defaultStyles';
import { TerminalCapability, type ITerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/capabilities';
import type { IPromptInputModel, IPromptInputModelState } from 'vs/platform/terminal/common/capabilities/commandDetection/promptInputModel';
import { ShellIntegrationOscPs } from 'vs/platform/terminal/common/xterm/shellIntegrationAddon';
import type { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private';
const enum VSCodeOscPt {
Completions = 'Completions',
CompletionsPwshCommands = 'CompletionsPwshCommands',
CompletionsBash = 'CompletionsBash',
CompletionsBashFirstWord = 'CompletionsBashFirstWord'
}
@@ -96,7 +99,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
constructor(
private readonly _capabilities: ITerminalCapabilityStore,
private readonly _terminalSuggestWidgetVisibleContextKey: IContextKey<boolean>,
@IInstantiationService private readonly _instantiationService: IInstantiationService
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService
) {
super();
@@ -134,7 +138,35 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
this._screen = screen;
}
private _requestCompletions(): void {
// TODO: Debounce? Prevent this flooding the channel
// if (this._terminal.
this._onAcceptedCompletion.fire('\x1b[24~e');
}
private _sync(promptInputState: IPromptInputModelState): void {
if (!this._terminalConfigurationService.config.shellIntegration?.suggestEnabled) {
return;
}
if (!this._terminalSuggestWidgetVisibleContextKey.get()) {
// If input has been added
if (!this._mostRecentPromptInputState || promptInputState.cursorIndex > this._mostRecentPromptInputState.cursorIndex) {
// Quick suggestions
if (promptInputState.cursorIndex === 1 || promptInputState.value.substring(0, promptInputState.cursorIndex).match(/\s[^\s]$/)) {
// TODO: Allow the user to configure terminal quickSuggestions
this._requestCompletions();
}
// Trigger characters
const lastChar = promptInputState.value.at(promptInputState.cursorIndex - 1);
if (lastChar?.match(/[\\\/\-]/)) {
// TODO: Allow the user to configure terminal suggestOnTriggerCharacters
this._requestCompletions();
}
}
}
this._mostRecentPromptInputState = promptInputState;
if (!this._promptInputModel || !this._terminal || !this._suggestWidget || !this._initialPromptInputState) {
return;
@@ -142,6 +174,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
this._currentPromptInputState = promptInputState;
// Hide the widget if the cursor moves to the left of the initial position as the
// completions are no longer valid
if (this._currentPromptInputState.cursorIndex < this._initialPromptInputState.cursorIndex) {
@@ -152,19 +185,15 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
if (this._terminalSuggestWidgetVisibleContextKey.get()) {
const inputBeforeCursor = this._currentPromptInputState.value.substring(0, this._currentPromptInputState.cursorIndex);
this._cursorIndexDelta = this._currentPromptInputState.cursorIndex - this._initialPromptInputState.cursorIndex;
this._suggestWidget.setLineContext(new LineContext(inputBeforeCursor, this._cursorIndexDelta));
}
// Hide and clear model if there are no more items
if (!this._suggestWidget.hasCompletions()) {
this.hideSuggestWidget();
// TODO: Don't request every time; refine completions
// this._onAcceptedCompletion.fire('\x1b[24~e');
return;
}
// TODO: Expose on xterm.js
const dimensions = this._getTerminalDimensions();
if (!dimensions.width || !dimensions.height) {
return;
@@ -191,6 +220,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
case VSCodeOscPt.Completions:
this._handleCompletionsSequence(this._terminal, data, command, args);
return true;
case VSCodeOscPt.CompletionsPwshCommands:
this._handleCompletionsPwshCommandsSequence(this._terminal, data, command, args);
case VSCodeOscPt.CompletionsBash:
this._handleCompletionsBashSequence(this._terminal, data, command, args);
return true;
@@ -204,18 +235,15 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
private _handleCompletionsSequence(terminal: Terminal, data: string, command: string, args: string[]): void {
// Nothing to handle if the terminal is not attached
if (!terminal.element || !this._enableWidget) {
if (!terminal.element || !this._enableWidget || !this._promptInputModel) {
return;
}
const replacementIndex = parseInt(args[0]);
const replacementLength = parseInt(args[1]);
if (!args[3]) {
this._onBell.fire();
return;
}
let replacementIndex = 0;
let replacementLength = this._promptInputModel.cursorIndex;
let completionList: IPwshCompletion[] | IPwshCompletion = JSON.parse(data.slice(command.length + args[0].length + args[1].length + args[2].length + 4/*semi-colons*/));
const payload = data.slice(command.length + args[0].length + args[1].length + args[2].length + 4/*semi-colons*/);
let completionList: IPwshCompletion[] | IPwshCompletion = args.length === 0 || payload.length === 0 ? [] : JSON.parse(payload);
if (!Array.isArray(completionList)) {
completionList = [completionList];
}
@@ -227,19 +255,45 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
});
});
this._leadingLineContent = completions[0].completion.label.slice(0, replacementLength);
this._leadingLineContent = this._promptInputModel.value.substring(0, this._promptInputModel.cursorIndex);
// If there's no space it means this is a command, add cached commands list to completions
if (!this._leadingLineContent.trim().includes(' ')) {
completions.push(...this._cachedPwshCommands);
} else {
replacementIndex = parseInt(args[0]);
replacementLength = parseInt(args[1]);
this._leadingLineContent = completions[0]?.completion.label.slice(0, replacementLength) ?? '';
}
this._cursorIndexDelta = 0;
const model = new SimpleCompletionModel(completions, new LineContext(this._leadingLineContent, replacementIndex), replacementIndex, replacementLength);
if (completions.length === 1) {
const insertText = completions[0].completion.label.substring(replacementLength);
if (insertText.length === 0) {
this._onBell.fire();
return;
}
}
this._handleCompletionModel(model);
}
// TODO: These aren't persisted across reloads
private _cachedPwshCommands: Set<SimpleCompletionItem> = new Set();
private _handleCompletionsPwshCommandsSequence(terminal: Terminal, data: string, command: string, args: string[]): void {
const type = args[0];
let completionList: IPwshCompletion[] | IPwshCompletion = JSON.parse(data.slice(command.length + type.length + 2/*semi-colons*/));
if (!Array.isArray(completionList)) {
completionList = [completionList];
}
const set = this._cachedPwshCommands;
set.clear();
const completions = completionList.map((e: any) => {
return new SimpleCompletionItem({
label: e.CompletionText,
icon: pwshTypeToIconMap[e.ResultType],
detail: e.ToolTip
});
});
for (const c of completions) {
set.add(c);
}
}
// TODO: These aren't persisted across reloads
// TODO: Allow triggering anywhere in the first word based on the cached completions
private _cachedBashAliases: Set<SimpleCompletionItem> = new Set();
@@ -338,9 +392,10 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
}
private _getTerminalDimensions(): { width: number; height: number } {
const cssCellDims = (this._terminal as any as { _core: IXtermCore })._core._renderService.dimensions.css.cell;
return {
width: (this._terminal as any)._core._renderService.dimensions.css.cell.width,
height: (this._terminal as any)._core._renderService.dimensions.css.cell.height,
width: cssCellDims.width,
height: cssCellDims.height,
};
}
@@ -384,14 +439,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
}));
this._register(this._suggestWidget.onDidSelect(async e => this.acceptSelectedSuggestion(e)));
this._register(this._suggestWidget.onDidHide(() => this._terminalSuggestWidgetVisibleContextKey.set(false)));
this._register(this._suggestWidget.onDidShow(() => {
this._initialPromptInputState = {
value: this._promptInputModel!.value,
cursorIndex: this._promptInputModel!.cursorIndex,
ghostTextIndex: this._promptInputModel!.ghostTextIndex
};
this._terminalSuggestWidgetVisibleContextKey.set(true);
}));
this._register(this._suggestWidget.onDidShow(() => this._terminalSuggestWidgetVisibleContextKey.set(true)));
}
return this._suggestWidget;
}
@@ -439,8 +487,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
// Send the completion
this._onAcceptedCompletion.fire([
// Disable suggestions
'\x1b[24~y',
// Backspace to remove all additional input
'\x7F'.repeat(additionalInput.length),
// Backspace to remove left side of completion
@@ -449,8 +495,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
finalCompletionLeftSide,
// Write the completion
finalCompletionRightSide,
// Enable suggestions
'\x1b[24~z',
].join(''));
this.hideSuggestWidget();