diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 908e5ee348..4cf4e3b958 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,10 +40,25 @@ extensions. ### PSES and Cross-Repo Work -The `modules/` folder contains the PSES, PSReadLine, and PSScriptAnalyzer PowerShell modules. In development it is a -symlink to `../PowerShellEditorServices/module` — [PowerShellEditorServices][] must be +The `modules/` folder contains the PSES, PSReadLine, and PSScriptAnalyzer PowerShell modules. In development the whole +folder is a symlink to `../PowerShellEditorServices/module` — [PowerShellEditorServices][] must be cloned as a sibling and built before `npm run compile` will succeed. For cross-repo work, use `pwsh-extension-dev.code-workspace`. +**Cross-repo dev/test cycle.** Because `modules/` is a symlink into the sibling PSES checkout's +`module/` directory, building PSES deploys its DLLs straight into the path the extension loads from — +there is no copy step: + +- **Edit PSES C# (server)** → rebuild PSES (e.g. `dotnet build src/PowerShellEditorServices/PowerShellEditorServices.csproj`, + or `Invoke-Build Build` for a full build). The build deploys into `module/PowerShellEditorServices/bin`, + which the symlinked `modules/` exposes to the extension automatically. The extension (and its tests) + then load the new DLL — no copy, but you must rebuild PSES, since the extension does not. +- **Edit extension TypeScript (client)** → `npm run compile`. +- **Verify end-to-end** → `npm test`. This launches a real VS Code Extension Host with PSES connected + and runs the Mocha suite, exercising the locally-built PSES through the symlink. Prefer this over + only eyeballing the Extension Development Host: it is the way to confirm cross-repo (client + server) + changes actually work, and to catch regressions. After changing a setting's default or any shared + behavior, run the full suite — e.g. ISE-compatibility tests assert against setting defaults. + ## Key Conventions - **VS Code best practices**: Follow the [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) and [UX Guidelines](https://code.visualstudio.com/api/ux-guidelines/overview). Use VS Code's APIs idiomatically and prefer disposable patterns for lifecycle management. diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 088f48f587..78d8ccc275 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -28,6 +28,9 @@ jobs: uses: actions/checkout@v6 with: repository: PowerShell/PowerShellEditorServices + # TEMPORARY: test against the coupled PSES PR #2298 until it merges. + # Revert this `ref` before merging (see PR #5508). + ref: andyleejordan/lm-tools-command-explorer path: PowerShellEditorServices - name: Checkout vscode-powershell diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 7b14da081f..e795504a3d 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,5 +1,7 @@ import { defineConfig } from "@vscode/test-cli"; import { existsSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; export default defineConfig({ files: "test/**/*.test.ts", @@ -10,6 +12,12 @@ export default defineConfig({ "--disable-extensions", // Undocumented but valid option to use a temporary profile for testing "--profile-temp", + // The default user-data-dir lives under the (deeply nested on CI) + // workspace path, and its IPC socket blows past macOS's 104-char + // unix-socket limit, causing a flaky `listen EINVAL`. Anchor it in the + // OS temp dir so the socket path stays short on every platform. + "--user-data-dir", + join(tmpdir(), "vscode-powershell-test"), ], workspaceFolder: `test/${existsSync("C:\\powershell-7\\pwsh.exe") ? "OneBranch" : "TestEnvironment"}.code-workspace`, mocha: { diff --git a/package.json b/package.json index d1e22eaa0c..30d21e7549 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,97 @@ "language": "powershell" } ], + "languageModelTools": [ + { + "name": "powershell_get_command", + "toolReferenceName": "getPowerShellCommand", + "displayName": "Get PowerShell Command", + "modelDescription": "Get the commands (cmdlets, functions, and scripts) available in the user's active PowerShell session, scoped by name and/or module. You must provide a 'name' and/or 'module' filter (at least one is required). Returns each matching command's name, module, default parameter set, and parameter names. Use this to discover the exact name, module, and parameters of a PowerShell command instead of guessing.", + "userDescription": "Lists commands available in the active PowerShell session.", + "canBeReferencedInPrompt": true, + "icon": "$(symbol-method)", + "tags": [ + "powershell" + ], + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Only return commands whose name matches this value (supports wildcards; bare text is matched as a substring), e.g. 'Get-ChildItem', 'ChildItem', or 'Get-*'. Provide this and/or 'module'." + }, + "module": { + "type": "string", + "description": "Only return commands from this module (supports wildcards), e.g. 'Microsoft.PowerShell.Management'. Provide this and/or 'name'." + } + } + } + }, + { + "name": "powershell_get_help", + "toolReferenceName": "getPowerShellHelp", + "displayName": "Get PowerShell Help", + "modelDescription": "Get the full help (Get-Help -Full) for a specific PowerShell command from the user's active session, including synopsis, syntax, parameter descriptions, and examples. Use this to ground answers about how a PowerShell command works and what parameters it accepts, instead of guessing.", + "userDescription": "Retrieves the full help for a PowerShell command.", + "canBeReferencedInPrompt": true, + "icon": "$(question)", + "tags": [ + "powershell" + ], + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The name of the PowerShell command to get help for, e.g. 'Get-ChildItem'." + } + }, + "required": [ + "command" + ] + } + }, + { + "name": "powershell_get_environment", + "toolReferenceName": "getPowerShellEnvironment", + "displayName": "Get PowerShell Environment", + "modelDescription": "Get details about the user's active PowerShell session, including the PowerShell version, edition (Core or Desktop), and process architecture. Use this before constructing version- or edition-specific PowerShell so that suggestions match the user's actual environment.", + "userDescription": "Reports the active PowerShell version, edition, and architecture.", + "canBeReferencedInPrompt": true, + "icon": "$(terminal-powershell)", + "tags": [ + "powershell" + ], + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "powershell_expand_alias", + "toolReferenceName": "expandPowerShellAlias", + "displayName": "Expand PowerShell Aliases", + "modelDescription": "Expand the aliases in a PowerShell script to their full command names (for example 'gci' becomes 'Get-ChildItem' and '?' becomes 'Where-Object') using the user's active PowerShell session. Use this to normalize or clarify aliased PowerShell before explaining or editing it.", + "userDescription": "Expands aliases in a PowerShell script to full command names.", + "canBeReferencedInPrompt": true, + "icon": "$(symbol-string)", + "tags": [ + "powershell" + ], + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The PowerShell script text whose aliases should be expanded." + } + }, + "required": [ + "text" + ] + } + } + ], "viewsContainers": { "activitybar": [ { @@ -391,12 +482,12 @@ "view/item/context": [ { "command": "PowerShell.ShowHelp", - "when": "view == PowerShellCommands", + "when": "view == PowerShellCommands && viewItem == command", "group": "inline@1" }, { "command": "PowerShell.InsertCommand", - "when": "view == PowerShellCommands", + "when": "view == PowerShellCommands && viewItem == command", "group": "inline@2" } ] @@ -748,7 +839,7 @@ }, "powershell.sideBar.CommandExplorerVisibility": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "Specifies the visibility of the Command Explorer in the side bar." }, "powershell.sideBar.CommandExplorerExcludeFilter": { diff --git a/src/extension.ts b/src/extension.ts index 5d47a60d32..e6002ad05e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { import { GetCommandsFeature } from "./features/GetCommands"; import { HelpCompletionFeature } from "./features/HelpCompletion"; import { ISECompatibilityFeature } from "./features/ISECompatibility"; +import { LanguageModelToolsFeature } from "./features/LanguageModelTools"; import { OpenInISEFeature } from "./features/OpenInISE"; import { PesterTestsFeature } from "./features/PesterTests"; import { RemoteFilesFeature } from "./features/RemoteFiles"; @@ -193,6 +194,7 @@ export async function activate( new RemoteFilesFeature(), new DebugSessionFeature(context, sessionManager, logger), new HelpCompletionFeature(), + new LanguageModelToolsFeature(), ]; sessionManager.setLanguageClientConsumers(languageClientConsumers); diff --git a/src/features/GetCommands.ts b/src/features/GetCommands.ts index 03ff476752..956d12aaf4 100644 --- a/src/features/GetCommands.ts +++ b/src/features/GetCommands.ts @@ -2,33 +2,81 @@ // Licensed under the MIT License. import * as vscode from "vscode"; -import { RequestType0 } from "vscode-languageclient"; +import { RequestType } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; import { LanguageClientConsumer } from "../languageClientConsumer"; +import { ShowHelpRequestType } from "./ShowHelp"; interface ICommand { name: string; moduleName: string; - defaultParameterSet: string; - parameterSets: object; - parameters: object; + moduleVersion?: string; + // Parameter metadata is omitted when the request sets excludeParameters + // (e.g. the Command Explorer tree, which only needs names and modules). + defaultParameterSet?: string; + parameterSets?: object; + parameters?: Record; +} + +export interface IGetCommandArguments { + name?: string; + module?: string; + excludeParameters?: boolean; + excludeDefaultFunctions?: boolean; } /** * RequestType sent over to PSES. - * Expects: ICommand to be returned + * Optionally scoped by command name and/or module (both support wildcards); + * when neither is provided, all commands are returned. Set excludeParameters to + * omit the expensive parameter metadata when only names/modules are needed, and + * excludeDefaultFunctions to drop PowerShell's default-session shell functions + * (e.g. cd.., prompt, TabExpansion2) that aren't meaningful in the command list. + * Expects: ICommand[] to be returned */ -export const GetCommandRequestType = new RequestType0( - "powerShell/getCommand", -); +export const GetCommandRequestType = new RequestType< + IGetCommandArguments, + ICommand[], + void +>("powerShell/getCommand"); + +interface IGetModuleArguments { + name: string; + version?: string; +} + +interface IModule { + name: string; + version: string; + description: string; + path: string; + author: string; + companyName: string; + projectUri: string; + powerShellVersion: string; +} + +/** + * RequestType sent over to PSES to retrieve a single module's metadata (used to + * populate the Command Explorer's module tooltips on hover). + */ +export const GetModuleRequestType = new RequestType< + IGetModuleArguments, + IModule | null, + void +>("powerShell/getModule"); + +type CommandExplorerNode = ModuleNode | CommandNode; /** - * A PowerShell Command listing feature. Implements a treeview control. + * A PowerShell Command listing feature. Implements a treeview control that + * groups commands by module, loading only command names and modules (parameter + * metadata is expensive to serialize and isn't shown in the tree). */ export class GetCommandsFeature extends LanguageClientConsumer { private commands: vscode.Disposable[]; private commandsExplorerProvider: CommandsExplorerProvider; - private commandsExplorerTreeView: vscode.TreeView; + private commandsExplorerTreeView: vscode.TreeView; constructor() { super(); @@ -48,10 +96,11 @@ export class GetCommandsFeature extends LanguageClientConsumer { ]; this.commandsExplorerProvider = new CommandsExplorerProvider(); - this.commandsExplorerTreeView = vscode.window.createTreeView( - "PowerShellCommands", - { treeDataProvider: this.commandsExplorerProvider }, - ); + this.commandsExplorerTreeView = + vscode.window.createTreeView( + "PowerShellCommands", + { treeDataProvider: this.commandsExplorerProvider }, + ); // Refresh the command explorer when the view is visible this.commandsExplorerTreeView.onDidChangeVisibility(async (e) => { @@ -77,7 +126,10 @@ export class GetCommandsFeature extends LanguageClientConsumer { private async CommandExplorerRefresh(): Promise { const client = await LanguageClientConsumer.getLanguageClient(); - const result = await client.sendRequest(GetCommandRequestType); + const result = await client.sendRequest(GetCommandRequestType, { + excludeParameters: true, + excludeDefaultFunctions: true, + }); const exclusions = vscode.workspace .getConfiguration("powershell.sideBar") .get("CommandExplorerExcludeFilter", []); @@ -88,9 +140,7 @@ export class GetCommandsFeature extends LanguageClientConsumer { (command) => !excludeFilter.includes(command.moduleName.toLowerCase()), ); - this.commandsExplorerProvider.powerShellCommands = - filteredResult.map(toCommand); - this.commandsExplorerProvider.refresh(); + this.commandsExplorerProvider.setCommands(filteredResult); } private async InsertCommand(item: { Name: string }): Promise { @@ -113,62 +163,205 @@ export class GetCommandsFeature extends LanguageClientConsumer { } } -class CommandsExplorerProvider implements vscode.TreeDataProvider { - public readonly onDidChangeTreeData: vscode.Event; - public powerShellCommands: Command[] = []; - private didChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); +class CommandsExplorerProvider implements vscode.TreeDataProvider { + public readonly onDidChangeTreeData: vscode.Event< + CommandExplorerNode | undefined + >; + private modules: ModuleNode[] = []; + // Tooltips are cached by key (command name / module+version) so they survive + // tree rebuilds and repeat hovers don't re-issue the (slow) request. + private readonly commandTooltips = new Map(); + private readonly moduleTooltips = new Map(); + private didChangeTreeData: vscode.EventEmitter< + CommandExplorerNode | undefined + > = new vscode.EventEmitter(); constructor() { this.onDidChangeTreeData = this.didChangeTreeData.event; } - public refresh(): void { + // Groups the flat command list into module -> command nodes. Commands are + // keyed by module name AND version, so a module installed in multiple versions + // (e.g. Pester 4 and 5) becomes separate rows rather than showing duplicate + // command names. + public setCommands(commands: ICommand[]): void { + // A refresh may reflect newly imported modules or updated help, so drop + // the cached tooltips and let them be re-fetched lazily on next hover. + this.commandTooltips.clear(); + this.moduleTooltips.clear(); + + const byModule = new Map< + string, + { moduleName: string; version: string; nodes: CommandNode[] } + >(); + for (const command of commands) { + const moduleName = command.moduleName || ""; + const version = command.moduleVersion ?? ""; + const key = `${moduleName}\u0000${version}`; + const group = byModule.get(key) ?? { + moduleName, + version, + nodes: [], + }; + group.nodes.push(new CommandNode(command.name, moduleName)); + byModule.set(key, group); + } + + this.modules = [...byModule.values()] + .map( + ({ moduleName, version, nodes }) => + new ModuleNode( + moduleName, + version, + nodes.sort((a, b) => a.Name.localeCompare(b.Name)), + ), + ) + .sort( + (a, b) => + // Group a module's versions together, newest first. + a.ModuleName.localeCompare(b.ModuleName) || + b.Version.localeCompare(a.Version, undefined, { + numeric: true, + }), + ); + this.didChangeTreeData.fire(undefined); } - public getTreeItem(element: Command): vscode.TreeItem { + public getTreeItem(element: CommandExplorerNode): vscode.TreeItem { return element; } - public getChildren(_element?: Command): Thenable { - return Promise.resolve(this.powerShellCommands); + // Lazily populates a node's tooltip the first time the user hovers it. Command + // nodes show their help (reusing the powerShell/showHelp request); module nodes + // show their metadata (powerShell/getModule). Results are cached by key so the + // (slow) request only runs once per command/module, even across tree rebuilds. + public async resolveTreeItem( + item: vscode.TreeItem, + element: CommandExplorerNode, + token: vscode.CancellationToken, + ): Promise { + if (item.tooltip !== undefined) { + return item; + } + + if (element instanceof CommandNode) { + const cached = this.commandTooltips.get(element.Name); + if (cached !== undefined) { + item.tooltip = cached; + return item; + } + + const client = await LanguageClientConsumer.getLanguageClient(); + const result = await client.sendRequest( + ShowHelpRequestType, + { text: element.Name }, + token, + ); + if (result.helpText) { + const tooltip = new vscode.MarkdownString(); + tooltip.appendCodeblock(result.helpText, "powershell"); + this.commandTooltips.set(element.Name, tooltip); + item.tooltip = tooltip; + } + return item; + } + + if (element instanceof ModuleNode && element.ModuleName) { + const key = `${element.ModuleName}\u0000${element.Version}`; + const cached = this.moduleTooltips.get(key); + if (cached !== undefined) { + item.tooltip = cached; + return item; + } + + const client = await LanguageClientConsumer.getLanguageClient(); + const module = await client.sendRequest( + GetModuleRequestType, + { name: element.ModuleName, version: element.Version }, + token, + ); + if (module) { + const tooltip = ModuleNode.buildTooltip( + module, + element.commands.length, + ); + this.moduleTooltips.set(key, tooltip); + item.tooltip = tooltip; + } + return item; + } + + return item; } -} -function toCommand(command: ICommand): Command { - return new Command( - command.name, - command.moduleName, - command.defaultParameterSet, - command.parameterSets, - command.parameters, - ); + public getChildren( + element?: CommandExplorerNode, + ): Thenable { + if (element === undefined) { + return Promise.resolve(this.modules); + } + if (element instanceof ModuleNode) { + return Promise.resolve(element.commands); + } + return Promise.resolve([]); + } } -class Command extends vscode.TreeItem { +class ModuleNode extends vscode.TreeItem { constructor( - public readonly Name: string, public readonly ModuleName: string, - public readonly defaultParameterSet: string, - public readonly ParameterSets: object, - public readonly Parameters: object, - public override readonly collapsibleState = vscode - .TreeItemCollapsibleState.None, + public readonly Version: string, + public readonly commands: CommandNode[], ) { - super(Name, collapsibleState); + super( + // Commands not exported by a module (built-in and profile-defined + // functions, and scripts on the PATH) are grouped under a friendly label. + ModuleName || "Functions & Scripts", + vscode.TreeItemCollapsibleState.Collapsed, + ); + this.contextValue = "module"; + // Real modules get the "library" icon; the catch-all bucket gets a neutral + // grouping icon so it doesn't read as an actual module. + this.iconPath = new vscode.ThemeIcon(ModuleName ? "library" : "folder"); + // Show the version next to the module name, which also disambiguates a + // module installed in more than one version. + if (Version) { + this.description = Version; + } } - public getTreeItem(): vscode.TreeItem { - return { - label: this.label, - collapsibleState: this.collapsibleState, - }; + // Builds a rich Markdown tooltip from a module's metadata. + public static buildTooltip( + module: IModule, + commandCount: number, + ): vscode.MarkdownString { + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown(`**${module.name}** ${module.version}\n\n`); + if (module.description) { + tooltip.appendMarkdown(`${module.description}\n\n`); + } + tooltip.appendMarkdown(`_${commandCount} commands_\n\n`); + if (module.author) { + tooltip.appendMarkdown(`Author: ${module.author}\n\n`); + } + if (module.projectUri) { + tooltip.appendMarkdown(`[Project](${module.projectUri})\n\n`); + } + if (module.path) { + tooltip.appendMarkdown(`\`${module.path}\``); + } + return tooltip; } +} - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/require-await - public async getChildren(_element?: any): Promise { - // Returning an empty array because we need to return something. - return []; +class CommandNode extends vscode.TreeItem { + constructor( + public readonly Name: string, + public readonly ModuleName: string, + ) { + super(Name, vscode.TreeItemCollapsibleState.None); + this.contextValue = "command"; + this.iconPath = new vscode.ThemeIcon("symbol-method"); } } diff --git a/src/features/LanguageModelTools.ts b/src/features/LanguageModelTools.ts new file mode 100644 index 0000000000..b5f2a55f09 --- /dev/null +++ b/src/features/LanguageModelTools.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as vscode from "vscode"; +import type { LanguageClient } from "vscode-languageclient/node"; +import { LanguageClientConsumer } from "../languageClientConsumer"; +import { PowerShellVersionRequestType } from "../session"; +import { ExpandAliasRequestType } from "./ExpandAlias"; +import { GetCommandRequestType } from "./GetCommands"; +import { ShowHelpRequestType } from "./ShowHelp"; + +function toToolResult(text: string): vscode.LanguageModelToolResult { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(text), + ]); +} + +interface IGetCommandInput { + name?: string; + module?: string; +} + +// Lists commands available in the active PowerShell session (backed by the +// existing powerShell/getCommand request), scoped by name and/or module. At +// least one filter is required so we never serialize the entire command table, +// which is prohibitively expensive. +class GetCommandTool implements vscode.LanguageModelTool { + public async invoke( + options: vscode.LanguageModelToolInvocationOptions, + _token: vscode.CancellationToken, + ): Promise { + const name = options.input.name?.trim(); + const module = options.input.module?.trim(); + + if (!name && !module) { + return toToolResult( + "Provide a 'name' and/or 'module' filter to look up PowerShell commands.", + ); + } + + // Get-Command -Name matches literally, so wrap bare text in wildcards to + // get intuitive "contains" matching while leaving explicit wildcards + // (and module names) untouched. + const namePattern = name && !/[*?[\]]/.test(name) ? `*${name}*` : name; + + const client = await LanguageClientConsumer.getLanguageClient(); + const matches = await client.sendRequest(GetCommandRequestType, { + name: namePattern, + module, + }); + + if (matches.length === 0) { + return toToolResult( + "No matching PowerShell commands were found in the current session.", + ); + } + + const limit = 50; + const limited = matches.slice(0, limit); + const blocks = limited.map((command) => { + const parameters = Object.keys(command.parameters ?? {}); + return [ + `Name: ${command.name}`, + `Module: ${command.moduleName || "(none)"}`, + `DefaultParameterSet: ${command.defaultParameterSet ?? "(none)"}`, + `Parameters: ${parameters.length > 0 ? parameters.join(", ") : "(none)"}`, + ].join("\n"); + }); + + let output = blocks.join("\n\n"); + if (matches.length > limit) { + output += `\n\n(Showing ${limit} of ${matches.length} matching commands. Provide a more specific name or module filter to narrow the results.)`; + } + + return toToolResult(output); + } +} + +interface IGetHelpInput { + command: string; +} + +// A tool that takes no input. +type EmptyInput = Record; + +// Returns the full Get-Help text for a command (backed by the powerShell/showHelp request). +class GetHelpTool implements vscode.LanguageModelTool { + public async invoke( + options: vscode.LanguageModelToolInvocationOptions, + _token: vscode.CancellationToken, + ): Promise { + const client = await LanguageClientConsumer.getLanguageClient(); + const result = await client.sendRequest(ShowHelpRequestType, { + text: options.input.command, + }); + return toToolResult( + result.helpText || `No help found for '${options.input.command}'.`, + ); + } +} + +// Reports the active PowerShell version/edition/architecture (backed by powerShell/getVersion). +class GetEnvironmentTool implements vscode.LanguageModelTool { + public async invoke( + _options: vscode.LanguageModelToolInvocationOptions, + _token: vscode.CancellationToken, + ): Promise { + const client = await LanguageClientConsumer.getLanguageClient(); + const version = await client.sendRequest(PowerShellVersionRequestType); + const output = [ + `PowerShell version: ${version.version}`, + `Edition: ${version.edition}`, + `Architecture: ${version.architecture}`, + `Commit: ${version.commit}`, + ].join("\n"); + return toToolResult(output); + } +} + +interface IExpandAliasInput { + text: string; +} + +// Expands aliases in a script to full command names (backed by powerShell/expandAlias). +class ExpandAliasTool implements vscode.LanguageModelTool { + public async invoke( + options: vscode.LanguageModelToolInvocationOptions, + _token: vscode.CancellationToken, + ): Promise { + const client = await LanguageClientConsumer.getLanguageClient(); + const result = await client.sendRequest(ExpandAliasRequestType, { + text: options.input.text, + }); + return toToolResult(result.text); + } +} + +export class LanguageModelToolsFeature extends LanguageClientConsumer { + private tools: vscode.Disposable[]; + + constructor() { + super(); + this.tools = [ + vscode.lm.registerTool( + "powershell_get_command", + new GetCommandTool(), + ), + vscode.lm.registerTool("powershell_get_help", new GetHelpTool()), + vscode.lm.registerTool( + "powershell_get_environment", + new GetEnvironmentTool(), + ), + vscode.lm.registerTool( + "powershell_expand_alias", + new ExpandAliasTool(), + ), + ]; + } + + public override onLanguageClientSet( + _languageClient: LanguageClient, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): void {} + + public dispose(): void { + for (const tool of this.tools) { + tool.dispose(); + } + } +} diff --git a/src/features/ShowHelp.ts b/src/features/ShowHelp.ts index 6af843e53b..0003ff47fe 100644 --- a/src/features/ShowHelp.ts +++ b/src/features/ShowHelp.ts @@ -2,50 +2,128 @@ // Licensed under the MIT License. import vscode = require("vscode"); -import { NotificationType } from "vscode-languageclient"; +import { RequestType } from "vscode-languageclient"; import type { LanguageClient } from "vscode-languageclient/node"; import { LanguageClientConsumer } from "../languageClientConsumer"; -interface IShowHelpNotificationArguments {} +export interface IShowHelpArguments { + text: string; +} + +export interface IShowHelpResult { + helpText: string; +} + +export const ShowHelpRequestType = new RequestType< + IShowHelpArguments, + IShowHelpResult, + void +>("powerShell/showHelp"); -export const ShowHelpNotificationType = - new NotificationType("powerShell/showHelp"); +// Serves Get-Help output as read-only virtual documents (scheme "powershell-help"), +// so help opens in an editor pane that is searchable and copyable but never marked +// dirty (no save/discard prompt). The command name is carried in the URI path. +class ShowHelpContentProvider implements vscode.TextDocumentContentProvider { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + public readonly onDidChange = this.onDidChangeEmitter.event; + + public refresh(uri: vscode.Uri): void { + this.onDidChangeEmitter.fire(uri); + } + + public async provideTextDocumentContent(uri: vscode.Uri): Promise { + const commandName = uri.path; + const client = await LanguageClientConsumer.getLanguageClient(); + const result = await client.sendRequest(ShowHelpRequestType, { + text: commandName, + }); + return result.helpText || `No help found for '${commandName}'.`; + } + + public dispose(): void { + this.onDidChangeEmitter.dispose(); + } +} export class ShowHelpFeature extends LanguageClientConsumer { + public static readonly scheme = "powershell-help"; + private command: vscode.Disposable; + private contentProvider: ShowHelpContentProvider; + private providerRegistration: vscode.Disposable; constructor() { super(); + this.contentProvider = new ShowHelpContentProvider(); + this.providerRegistration = + vscode.workspace.registerTextDocumentContentProvider( + ShowHelpFeature.scheme, + this.contentProvider, + ); + this.command = vscode.commands.registerCommand( "PowerShell.ShowHelp", async (item?) => { - if (!item?.Name) { - const editor = vscode.window.activeTextEditor; - if (editor === undefined) { - return; - } - - const selection = editor.selection; - const doc = editor.document; - const cwr = doc.getWordRangeAtPosition(selection.active); - const text = doc.getText(cwr); - - const client = - await LanguageClientConsumer.getLanguageClient(); - await client.sendNotification(ShowHelpNotificationType, { - text, - }); - } else { - const client = - await LanguageClientConsumer.getLanguageClient(); - await client.sendNotification(ShowHelpNotificationType, { - text: item.Name, - }); + const text = ShowHelpFeature.resolveCommandName(item); + if (text === undefined) { + return; } + + await this.showHelp(text); }, ); } + // Determines the command to show help for: an explicit item, the current + // selection, or the word under the cursor. Returns undefined (and surfaces a + // hint) when there's nothing to look up, rather than falling back to the + // entire document. + private static resolveCommandName(item?: { + Name?: string; + }): string | undefined { + if (item?.Name) { + return item.Name; + } + + const editor = vscode.window.activeTextEditor; + if (editor === undefined) { + return undefined; + } + + const document = editor.document; + const selection = editor.selection; + + if (!selection.isEmpty) { + return document.getText(selection); + } + + const wordRange = document.getWordRangeAtPosition(selection.active); + if (wordRange === undefined) { + void vscode.window.showInformationMessage( + "Place the cursor on a PowerShell command to show its help.", + ); + return undefined; + } + + return document.getText(wordRange); + } + + private async showHelp(commandName: string): Promise { + const uri = vscode.Uri.from({ + scheme: ShowHelpFeature.scheme, + path: commandName, + }); + + // Re-fetch in case the help changed since this document was last opened. + this.contentProvider.refresh(uri); + + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { + preview: true, + viewColumn: vscode.ViewColumn.Beside, + }); + } + public override onLanguageClientSet( _languageClient: LanguageClient, // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -53,5 +131,7 @@ export class ShowHelpFeature extends LanguageClientConsumer { public dispose(): void { this.command.dispose(); + this.providerRegistration.dispose(); + this.contentProvider.dispose(); } } diff --git a/test/features/ISECompatibility.test.ts b/test/features/ISECompatibility.test.ts index 19a768c56a..06f171fe1f 100644 --- a/test/features/ISECompatibility.test.ts +++ b/test/features/ISECompatibility.test.ts @@ -19,6 +19,22 @@ describe("ISE compatibility feature", function () { await vscode.commands.executeCommand("PowerShell.ToggleISEMode"); } + // A setting whose default already equals its ISE value (e.g. an always-visible + // Command Explorer) stays at that value after ISE mode is disabled, so its + // revert can't be observed by comparing against the ISE value. Such settings + // are skipped by the revert assertions below. + function revertIsObservable(iseSetting: { + path: string; + name: string; + value: string | boolean; + }): boolean { + return ( + vscode.workspace + .getConfiguration(iseSetting.path) + .inspect(iseSetting.name)?.defaultValue !== iseSetting.value + ); + } + before(async function () { // Save user's current theme. currentTheme = await vscode.workspace @@ -57,6 +73,9 @@ describe("ISE compatibility feature", function () { after(disableISEMode); for (const iseSetting of ISECompatibilityFeature.settings) { it(`Reverts ${iseSetting.name} correctly`, function () { + if (!revertIsObservable(iseSetting)) { + this.skip(); + } const currently = vscode.workspace .getConfiguration(iseSetting.path) .get(iseSetting.name); @@ -71,6 +90,9 @@ describe("ISE compatibility feature", function () { after(disableISEMode); for (const iseSetting of ISECompatibilityFeature.settings) { it(`Reverts ${iseSetting.name} correctly`, function () { + if (!revertIsObservable(iseSetting)) { + this.skip(); + } const currently = vscode.workspace .getConfiguration(iseSetting.path) .get(iseSetting.name); @@ -98,6 +120,9 @@ describe("ISE compatibility feature", function () { function assertISESettings(): void { for (const iseSetting of ISECompatibilityFeature.settings) { + if (!revertIsObservable(iseSetting)) { + continue; + } const currently = vscode.workspace .getConfiguration(iseSetting.path) .get(iseSetting.name); diff --git a/test/features/LanguageModelTools.test.ts b/test/features/LanguageModelTools.test.ts new file mode 100644 index 0000000000..5b5dc1e163 --- /dev/null +++ b/test/features/LanguageModelTools.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as assert from "assert"; +import * as vscode from "vscode"; +import utils = require("../utils"); + +function getToolResultText(result: vscode.LanguageModelToolResult): string { + return result.content + .filter( + (part): part is vscode.LanguageModelTextPart => + part instanceof vscode.LanguageModelTextPart, + ) + .map((part) => part.value) + .join(""); +} + +async function invokeTool(name: string, input: object): Promise { + const result = await vscode.lm.invokeTool(name, { + input, + toolInvocationToken: undefined, + }); + return getToolResultText(result); +} + +describe("Language model tools feature", function () { + before(async function () { + await utils.ensureEditorServicesIsConnected(); + }); + + const expectedTools = [ + "powershell_get_command", + "powershell_get_help", + "powershell_get_environment", + "powershell_expand_alias", + ]; + + for (const name of expectedTools) { + it(`Registers the ${name} tool`, function () { + assert.ok( + vscode.lm.tools.some((tool) => tool.name === name), + `Expected tool '${name}' to be registered.`, + ); + }); + } + + it("Gets the PowerShell environment", async function () { + const text = await invokeTool("powershell_get_environment", {}); + assert.match(text, /PowerShell version:/); + assert.match(text, /Edition:/); + }); + + it("Finds a command by name", async function () { + const text = await invokeTool("powershell_get_command", { + name: "Get-Command", + }); + assert.match(text, /Get-Command/); + }); + + it("Finds commands by module", async function () { + const text = await invokeTool("powershell_get_command", { + module: "Microsoft.PowerShell.Management", + }); + assert.match(text, /Microsoft\.PowerShell\.Management/); + }); + + it("Requires a filter for get_command", async function () { + const text = await invokeTool("powershell_get_command", {}); + assert.match(text, /Provide a 'name' and\/or 'module' filter/); + }); + + it("Gets help for a command", async function () { + const text = await invokeTool("powershell_get_help", { + command: "Get-Command", + }); + assert.ok(text.length > 0, "Expected non-empty help text."); + assert.match(text, /Get-Command/); + }); + + it("Expands an alias", async function () { + const text = await invokeTool("powershell_expand_alias", { + text: "gci", + }); + assert.match(text, /Get-ChildItem/); + }); +});