diff --git a/src/vs/platform/terminal/common/terminalRecorder.ts b/src/vs/platform/terminal/common/terminalRecorder.ts index d8fcb026948..79a828cc220 100644 --- a/src/vs/platform/terminal/common/terminalRecorder.ts +++ b/src/vs/platform/terminal/common/terminalRecorder.ts @@ -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 { diff --git a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 index c22f7d64224..f2f50c46717 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 @@ -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 diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 97f735e675c..a53fb826303 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -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); } diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 3117275afe9..8f4d1d85dab 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -202,6 +202,7 @@ export interface ITerminalConfiguration { shellIntegration?: { enabled: boolean; decorationsEnabled: boolean; + suggestEnabled: boolean; }; enableImages: boolean; smoothScrolling: boolean; diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index a3398afe76c..b5d59cb1ea6 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -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 diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 6117f3ad916..b8af40c5e61 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -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, - @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 = 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 = 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();