From 0b48dd24f37f80a0c54be76a87ab4c8c04c42b8f Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:22:12 -0700 Subject: [PATCH 1/6] Render Show Help in a read-only editor pane Show Help previously printed `Get-Help` output into the integrated console and, when nothing was selected, sent the entire document as the help target. The latter was a `getWordRangeAtPosition` bug: off a word it returns `undefined`, and `doc.getText(undefined)` returns the whole document. Resolve the word at the cursor explicitly (falling back to the selection), send it to the new `powerShell/showHelp` request, and render the returned text in a read-only virtual document so help opens in a proper editor pane instead of an editable untitled buffer. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/features/ShowHelp.ts | 132 +++++++++++++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 26 deletions(-) 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(); } } From f893afe20a8554be4b1895433e5424bc200283e6 Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:22:53 -0700 Subject: [PATCH 2/6] Redesign the Command Explorer around modules and lazy loading The Command Explorer fetched the full command table up front (names, modules, and parameter metadata for everything), which took minutes to populate and showed a flat, hard-to-scan list. Rework it into a lazy, module-grouped tree: - Top-level nodes are modules (with version), expanded on demand; their commands are fetched per module with `excludeParameters` so only names and modules cross the wire. - Module-less commands are collected under a neutral "Functions & Scripts" node with a folder icon, and the request sets `excludeDefaultFunctions` so PowerShell's default-session plumbing doesn't clutter it. - Hovering a module shows its metadata via the new `powerShell/getModule` request, cached so repeated hovers don't re-fetch. - The inline Show Help / Insert command actions are gated to `viewItem == command` so they no longer appear on module nodes. Default the Command Explorer to visible. Because its default now equals the value the ISE profile sets, the ISE compatibility tests could no longer observe a revert by inequality; guard those assertions with `revertIsObservable`, which skips settings whose default already matches the ISE value. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 6 +- src/features/GetCommands.ts | 299 ++++++++++++++++++++----- test/features/ISECompatibility.test.ts | 25 +++ 3 files changed, 274 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index d1e22eaa0c..a1f46802a9 100644 --- a/package.json +++ b/package.json @@ -391,12 +391,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 +748,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/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/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); From c236b9902defb57ddf58b39fe19d852a30644569 Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:23:12 -0700 Subject: [PATCH 3/6] Add PowerShell language model tools Register four read-only language model tools that Copilot can call when working with PowerShell, each backed by an existing PSES request so they report exactly what the user's session would: - `powershell_get_command` lists commands, optionally scoped by name and/or module, using the now-parameterized `powerShell/getCommand` request with `excludeParameters` so large listings stay cheap. - `powershell_get_help` returns `Get-Help` output via `powerShell/showHelp`. - `powershell_get_environment` reports the PowerShell version table. - `powershell_expand_alias` resolves aliases to their underlying commands. All four are read-only and declare no confirmation requirement. Contribute them in `package.json` and wire up `LanguageModelToolsFeature` in `extension.ts`. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 91 ++++++++++++ src/extension.ts | 2 + src/features/LanguageModelTools.ts | 170 +++++++++++++++++++++++ test/features/LanguageModelTools.test.ts | 86 ++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 src/features/LanguageModelTools.ts create mode 100644 test/features/LanguageModelTools.test.ts diff --git a/package.json b/package.json index a1f46802a9..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": [ { 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/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/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/); + }); +}); From bebd876f8b3e5abd75aad1ace89012fa8ab3c5c3 Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:23:28 -0700 Subject: [PATCH 4/6] Document the cross-repo dev and test cycle for Copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that `modules/` is a symlink to the sibling PowerShellEditorServices `module` directory, so building PSES deploys its DLLs straight into the path the extension and its tests load from — meaning C# changes require rebuilding PSES by hand, and `npm test` exercises the real Extension Host against the locally built server. This trips up anyone (including Copilot) who edits PSES and wonders why the extension didn't pick it up. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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. From e98f19606e0b21f8363207c5c6a18ef45799608b Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:48:52 -0700 Subject: [PATCH 5/6] TEMPORARY: Build CI against PSES PR #2298 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point the `PowerShellEditorServices` checkout at the coupled PR branch `andyleejordan/lm-tools-command-explorer` so #5508's new language model tool tests run against the server contract they depend on (name/module filtering on `getCommand`, `showHelp` returning `helpText`). Must be reverted before merge — once PSES #2298 lands on `main`, the default checkout passes. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-test.yml | 3 +++ 1 file changed, 3 insertions(+) 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 From 909db48c703842285b77d3c1ab837a73efdfca0c Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:03:15 -0700 Subject: [PATCH 6/6] Fix flaky macOS CI from over-long test IPC socket path `@vscode/test-cli` defaults `--user-data-dir` to `.vscode-test/user-data` inside the workspace. On CI the workspace is nested three deep (`.../vscode-powershell/vscode-powershell/vscode-powershell/`), so the VS Code instance's IPC socket (`/-main.sock`) exceeds macOS's 104-char `AF_UNIX` limit and the editor dies at startup with `listen EINVAL`, flaking the whole matrix (and cancelling the other OSes via fail-fast). Anchor `--user-data-dir` in `os.tmpdir()` so the socket path stays short on every platform. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode-test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) 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: {