Merge pull request #294774 from microsoft/josh/upstream-newpromptactions

prompt actions: add extension points for folder and editor overrides
This commit is contained in:
Josh Spicer
2026-02-12 15:45:10 -08:00
committed by GitHub
parent 1ca69349dd
commit aad1561e22
2 changed files with 108 additions and 45 deletions

View File

@@ -96,7 +96,8 @@ async function addHookToFile(
fileService: IFileService,
editorService: IEditorService,
notificationService: INotificationService,
bulkEditService: IBulkEditService
bulkEditService: IBulkEditService,
openEditorOverride?: (resource: URI, options?: { selection?: ITextEditorSelection }) => Promise<void>,
): Promise<void> {
// Parse existing file
let hooksContent: { hooks: Record<string, unknown[]> };
@@ -240,13 +241,17 @@ async function addHookToFile(
const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');
// Open editor with selection (or re-focus if already open)
await editorService.openEditor({
resource: hookFileUri,
options: {
selection,
pinned: false
}
});
if (openEditorOverride) {
await openEditorOverride(hookFileUri, { selection });
} else {
await editorService.openEditor({
resource: hookFileUri,
options: {
selection,
pinned: false
}
});
}
}
}
@@ -290,12 +295,25 @@ const enum Step {
EnterFilename = 5,
}
/**
* Optional callbacks for customizing the hook creation and opening behaviour.
* The agentic editor passes these to open hooks in the embedded editor and
* track worktree files for auto-commit.
*/
export interface IHookQuickPickCallbacks {
/** Override how the hook file is opened. If not provided, uses editorService.openEditor. */
readonly openEditor?: (resource: URI, options?: { selection?: ITextEditorSelection }) => Promise<void>;
/** Called after a new hook file is created on disk. */
readonly onHookFileCreated?: (uri: URI) => void;
}
/**
* Shows the Configure Hooks quick pick UI, allowing the user to view,
* open, or create hooks. Can be called from the action or slash command.
*/
export async function showConfigureHooksQuickPick(
accessor: ServicesAccessor,
callbacks?: IHookQuickPickCallbacks,
): Promise<void> {
const promptsService = accessor.get(IPromptsService);
const quickInputService = accessor.get(IQuickInputService);
@@ -470,13 +488,17 @@ export async function showConfigureHooksQuickPick(
}
picker.hide();
await editorService.openEditor({
resource: entry.fileUri,
options: {
selection,
pinned: false
}
});
if (callbacks?.openEditor) {
await callbacks.openEditor(entry.fileUri, { selection });
} else {
await editorService.openEditor({
resource: entry.fileUri,
options: {
selection,
pinned: false
}
});
}
return;
}
@@ -548,7 +570,8 @@ export async function showConfigureHooksQuickPick(
fileService,
editorService,
notificationService,
bulkEditService
bulkEditService,
callbacks?.openEditor,
);
return;
}
@@ -679,7 +702,8 @@ export async function showConfigureHooksQuickPick(
fileService,
editorService,
notificationService,
bulkEditService
bulkEditService,
callbacks?.openEditor,
);
return;
}
@@ -704,18 +728,24 @@ export async function showConfigureHooksQuickPick(
const jsonContent = JSON.stringify(hooksContent, null, '\t');
await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent));
callbacks?.onHookFileCreated?.(hookFileUri);
// Find the selection for the new hook's command field
const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command');
// Open editor with selection
store.dispose();
await editorService.openEditor({
resource: hookFileUri,
options: {
selection,
pinned: false
}
});
if (callbacks?.openEditor) {
await callbacks.openEditor(hookFileUri, { selection });
} else {
await editorService.openEditor({
resource: hookFileUri,
options: {
selection,
pinned: false
}
});
}
return;
}
}

View File

@@ -5,7 +5,7 @@
import { isEqual } from '../../../../../base/common/resources.js';
import { URI } from '../../../../../base/common/uri.js';
import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js';
import { getCodeEditor, ICodeEditor } from '../../../../../editor/browser/editorBrowser.js';
import { SnippetController2 } from '../../../../../editor/contrib/snippet/browser/snippetController2.js';
import { localize, localize2 } from '../../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
@@ -26,9 +26,19 @@ import { askForPromptFileName } from './pickers/askForPromptName.js';
import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js';
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js';
import { Target } from '../../common/promptSyntax/service/promptsService.js';
import { Target, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
import { getTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js';
/**
* Options to override the default folder-picker and editor-open behaviour
* of the new-prompt-file actions. The agentic editor passes these to open
* files in the embedded editor and pre-resolve the target folder.
*/
export interface INewPromptOptions {
readonly targetFolder?: URI;
readonly targetStorage?: PromptsStorage;
readonly openFile?: (uri: URI) => Promise<ICodeEditor | undefined>;
}
class AbstractNewPromptFileAction extends Action2 {
@@ -49,7 +59,7 @@ class AbstractNewPromptFileAction extends Action2 {
});
}
public override async run(accessor: ServicesAccessor) {
public override async run(accessor: ServicesAccessor, options?: INewPromptOptions) {
const logService = accessor.get(ILogService);
const openerService = accessor.get(IOpenerService);
const commandService = accessor.get(ICommandService);
@@ -59,27 +69,40 @@ class AbstractNewPromptFileAction extends Action2 {
const fileService = accessor.get(IFileService);
const instaService = accessor.get(IInstantiationService);
const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, this.type);
if (!selectedFolder) {
return;
let folderUri: URI;
let storage: string;
if (options?.targetFolder) {
folderUri = options.targetFolder;
storage = options.targetStorage ?? PromptsStorage.local;
} else {
const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, this.type);
if (!selectedFolder) {
return;
}
folderUri = selectedFolder.uri;
storage = selectedFolder.storage;
}
const fileName = await instaService.invokeFunction(askForPromptFileName, this.type, selectedFolder.uri);
const fileName = await instaService.invokeFunction(askForPromptFileName, this.type, folderUri);
if (!fileName) {
return;
}
// create the prompt file
await fileService.createFolder(selectedFolder.uri);
await fileService.createFolder(folderUri);
const promptUri = URI.joinPath(selectedFolder.uri, fileName);
const promptUri = URI.joinPath(folderUri, fileName);
await fileService.createFile(promptUri);
await openerService.open(promptUri);
const cleanName = getCleanPromptName(promptUri);
const editor = getCodeEditor(editorService.activeTextEditorControl);
let editor: ICodeEditor | null | undefined;
if (options?.openFile) {
editor = await options.openFile(promptUri);
} else {
await openerService.open(promptUri);
editor = getCodeEditor(editorService.activeTextEditorControl);
}
if (editor && editor.hasModel() && isEqual(editor.getModel().uri, promptUri)) {
SnippetController2.get(editor)?.apply([{
range: editor.getModel().getFullModelRange(),
@@ -87,7 +110,7 @@ class AbstractNewPromptFileAction extends Action2 {
}]);
}
if (selectedFolder.storage !== 'user') {
if (storage !== 'user') {
return;
}
@@ -247,16 +270,22 @@ class NewSkillFileAction extends Action2 {
});
}
public override async run(accessor: ServicesAccessor) {
public override async run(accessor: ServicesAccessor, options?: INewPromptOptions) {
const openerService = accessor.get(IOpenerService);
const editorService = accessor.get(IEditorService);
const fileService = accessor.get(IFileService);
const instaService = accessor.get(IInstantiationService);
const quickInputService = accessor.get(IQuickInputService);
const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.skill);
if (!selectedFolder) {
return;
let folderUri: URI;
if (options?.targetFolder) {
folderUri = options.targetFolder;
} else {
const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.skill);
if (!selectedFolder) {
return;
}
folderUri = selectedFolder.uri;
}
// Ask for skill name (will be the folder name)
@@ -294,15 +323,19 @@ class NewSkillFileAction extends Action2 {
const trimmedName = skillName.trim();
// Create the skill folder and SKILL.md file
const skillFolder = URI.joinPath(selectedFolder.uri, trimmedName);
const skillFolder = URI.joinPath(folderUri, trimmedName);
await fileService.createFolder(skillFolder);
const skillFileUri = URI.joinPath(skillFolder, SKILL_FILENAME);
await fileService.createFile(skillFileUri);
await openerService.open(skillFileUri);
const editor = getCodeEditor(editorService.activeTextEditorControl);
let editor: ICodeEditor | null | undefined;
if (options?.openFile) {
editor = await options.openFile(skillFileUri);
} else {
await openerService.open(skillFileUri);
editor = getCodeEditor(editorService.activeTextEditorControl);
}
if (editor && editor.hasModel() && isEqual(editor.getModel().uri, skillFileUri)) {
SnippetController2.get(editor)?.apply([{
range: editor.getModel().getFullModelRange(),