diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts index ec2a4e021c6..3e65dbffb9c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts @@ -8,9 +8,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { basename } from '../../../../base/common/resources.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IChatRequestFileEntry, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry } from '../common/chatVariableEntries.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ChatPromptAttachmentsCollection } from './chatAttachmentModel/chatPromptAttachments.js'; +import { IChatRequestFileEntry, IChatRequestVariableEntry, isPromptFileVariableEntry } from '../common/chatVariableEntries.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; @@ -28,22 +26,17 @@ export interface IChatAttachmentChangeEvent { export class ChatAttachmentModel extends Disposable { - private readonly _promptInstructions: ChatPromptAttachmentsCollection; private readonly _attachments = new Map(); private _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; constructor( - @IInstantiationService instaService: IInstantiationService, @IFileService private readonly fileService: IFileService, @IDialogService private readonly dialogService: IDialogService, @ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService, ) { super(); - - this._promptInstructions = this._register(instaService.createInstance(ChatPromptAttachmentsCollection)); - this._register(this._promptInstructions.onUpdate(variable => this._onDidChange.fire({ updated: [variable], deleted: [], added: [] }))); } get attachments(): ReadonlyArray { @@ -75,23 +68,6 @@ export class ChatAttachmentModel extends Disposable { } } - addPromptFiles(promptFiles: readonly URI[]): void { - const variables = promptFiles.map(uri => toPromptFileVariableEntry(uri, true)); - this.addContext(...variables); - } - - hasPromptFiles(languageId: string): boolean { - return this._promptInstructions.hasPromptFiles(languageId); - } - - /** - * Get the list of all prompt instruction attachment variables, including all - * nested child references of each attachment explicitly attached by user. - */ - getPromptFileVariables(): Promise { - return this._promptInstructions.getAttachments(); - } - addFolder(uri: URI) { this.addContext({ kind: 'directory', @@ -105,7 +81,6 @@ export class ChatAttachmentModel extends Disposable { if (clearStickyAttachments) { const deleted = Array.from(this._attachments.keys()); this._attachments.clear(); - this._promptInstructions.clear(); this._onDidChange.fire({ deleted, added: [], updated: [] }); } else { const deleted: string[] = []; @@ -114,6 +89,7 @@ export class ChatAttachmentModel extends Disposable { const entry = this._attachments.get(id); if (entry && !isPromptFileVariableEntry(entry)) { this._attachments.delete(id); + deleted.push(id); } } this._onDidChange.fire({ deleted, added: [], updated: [] }); @@ -143,9 +119,6 @@ export class ChatAttachmentModel extends Disposable { if (item) { this._attachments.delete(id); deleted.push(id); - if (isPromptFileVariableEntry(item)) { - this._promptInstructions.remove(item); - } } } @@ -154,9 +127,6 @@ export class ChatAttachmentModel extends Disposable { if (!oldItem) { this._attachments.set(item.id, item); added.push(item); - if (isPromptFileVariableEntry(item)) { - this._promptInstructions.add([item]); - } } else if (!equals(oldItem, item)) { this._attachments.set(item.id, item); updated.push(item); diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachments.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachments.ts deleted file mode 100644 index 20e1bb26994..00000000000 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel/chatPromptAttachments.ts +++ /dev/null @@ -1,144 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancelablePromise, createCancelablePromise } from '../../../../../base/common/async.js'; -import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../../base/common/map.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { IModelService } from '../../../../../editor/common/services/model.js'; -import { IChatRequestVariableEntry, IPromptFileVariableEntry, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js'; -import { IPromptParserResult, IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; - -/** - * Model for a collection of prompt instruction attachments. - * Starts - */ -export class ChatPromptAttachmentsCollection extends Disposable { - /** - * Event that fires then this model is updated. - * - * See {@linkcode onUpdate}. - */ - protected _onUpdate = this._register(new Emitter()); - - /** - * Subscribe to the `onUpdate` event. - */ - public onUpdate = this._onUpdate.event; - - /** - * List of all prompt instruction attachments. - */ - private _attachments = new ResourceMap>(); - - - constructor( - @ILanguageService private readonly languageService: ILanguageService, - @IModelService private readonly modelService: IModelService, - @IPromptsService private readonly promptsService: IPromptsService, - ) { - super(); - } - - /** - * Check if any of the attachments is a prompt file. - */ - public hasPromptFiles(promptFileLanguageId: string): boolean { - const hasLanguage = (uri: URI) => { - const model = this.modelService.getModel(uri); - const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri); - return languageId === promptFileLanguageId; - }; - - for (const uri of this._attachments.keys()) { - if (hasLanguage(uri)) { - return true; - } - } - return false; - } - - /** - * Get the list of all prompt instruction attachment variables, including all - * nested child references of each attachment explicitly attached by user. - */ - public async getAttachments(): Promise { - const result = []; - const attachments = [...this._attachments.values()]; - - for (const parseResultPromise of attachments) { - const parseResult = await parseResultPromise; - - // the usual URIs list of prompt instructions is `bottom-up`, therefore - // we do the same here - first add all child references of the model - for (const uri of parseResult.allValidReferences) { - result.push(toPromptFileVariableEntry(uri, false)); - } - - // then add the root reference of the model itself - result.push(toPromptFileVariableEntry(parseResult.uri, true)); - } - - return result; - } - - /** - * Add prompt instruction attachment instances - */ - public add(entries: IPromptFileVariableEntry[]) { - for (const entry of entries) { - const uri = entry.value; - - // if already exists, nothing to do - if (this._attachments.has(uri)) { - continue; - } - - const parseResult = createCancelablePromise(token => this.promptsService.parse(uri, token)); - parseResult.then(() => { - this._onUpdate.fire(entry); - }).catch((error) => { - // if parsing fails, we still create an attachment model - // to allow the user to see the error and fix it - console.error(`Failed to parse prompt file ${uri.toString()}:`, error); - }); - this._attachments.set(uri, parseResult); - } - } - - /** - * Remove a prompt instruction attachment instancs - */ - public remove(entry: IPromptFileVariableEntry): this { - const uri = entry.value; - - const attachment = this._attachments.get(uri); - if (attachment) { - this._attachments.delete(uri); - attachment.cancel(); - } - - return this; - } - - /** - * Clear all prompt instruction attachments. - */ - public clear(): this { - for (const attachment of this._attachments.values()) { - attachment.cancel(); - } - this._attachments.clear(); - - return this; - } - - public override dispose(): void { - super.dispose(); - this.clear(); // disposes of all attachments - } -} diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts index ffda839a0a2..aa39a017e47 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts @@ -25,7 +25,7 @@ import { IOpenerService, OpenInternalOptions } from '../../../../platform/opener import { IThemeService, FolderThemeIcon } from '../../../../platform/theme/common/themeService.js'; import { IResourceLabel, ResourceLabels, IFileLabelOptions } from '../../../browser/labels.js'; import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js'; -import { IChatRequestPasteVariableEntry, IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, ISCMHistoryItemVariableEntry, OmittedState } from '../common/chatVariableEntries.js'; +import { IChatRequestPasteVariableEntry, IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, ISCMHistoryItemVariableEntry, OmittedState } from '../common/chatVariableEntries.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { basename, dirname } from '../../../../base/common/path.js'; @@ -54,6 +54,8 @@ import { getHistoryItemEditorTitle, getHistoryItemHoverContent } from '../../scm import { ILanguageModelToolsService, ToolSet } from '../common/languageModelToolsService.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { getCleanPromptName } from '../common/promptSyntax/config/promptFileLocations.js'; +import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../common/promptSyntax/promptTypes.js'; abstract class AbstractChatAttachmentWidget extends Disposable { public readonly element: HTMLElement; @@ -481,7 +483,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget { constructor( resource: URI, - attachment: IChatRequestVariableEntry, + attachment: IPromptFileVariableEntry, currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, container: HTMLElement, @@ -490,7 +492,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget { @ICommandService commandService: ICommandService, @IOpenerService openerService: IOpenerService, @ILabelService private readonly labelService: ILabelService, - @ILanguageService private readonly languageService: ILanguageService, + @IPromptsService private readonly promptService: IPromptsService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService); @@ -498,7 +500,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget { this.hintElement = dom.append(this.element, dom.$('span.prompt-type')); - this.updateLabel(resource); + this.updateLabel(attachment); this.instantiationService.invokeFunction(accessor => { this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource)); @@ -508,11 +510,12 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget { this.attachClearButton(); } - private updateLabel(resource: URI) { + private updateLabel(attachment: IPromptFileVariableEntry) { + const resource = attachment.value; const fileBasename = basename(resource.path); const fileDirname = dirname(resource.path); const friendlyName = `${fileBasename} ${fileDirname}`; - const isPrompt = this.languageService.guessLanguageIdByFilepathOrFirstLine(resource) === 'prompt'; + const isPrompt = this.promptService.getPromptFileType(resource) === PromptsType.prompt; const ariaLabel = isPrompt ? localize('chat.promptAttachment', "Prompt file, {0}", friendlyName) : localize('chat.instructionsAttachment', "Instructions attachment, {0}", friendlyName); @@ -520,8 +523,7 @@ export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget { ? localize('prompt', "Prompt") : localize('instructions', "Instructions"); - const uriLabel = this.labelService.getUriLabel(resource, { relative: true }); - const title = `${typeLabel} ${uriLabel}`; + const title = this.labelService.getUriLabel(resource) + (attachment.originLabel ? `\n${attachment.originLabel}` : ''); //const { topError } = this.promptFile; this.element.classList.remove('warning', 'error'); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 4281146eba3..48cab8184f9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -101,7 +101,8 @@ import { ChatRelatedFiles } from './contrib/chatInputRelatedFilesContrib.js'; import { resizeImage } from './imageUtils.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPicker/modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modelPicker/modePickerActionItem.js'; -import { PROMPT_LANGUAGE_ID } from '../common/promptSyntax/promptTypes.js'; +import { PromptsType } from '../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; const $ = dom.$; @@ -163,18 +164,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly selectedToolsModel: ChatSelectedTools; - public async getAttachedAndImplicitContext(sessionId: string): Promise { + public getAttachedAndImplicitContext(sessionId: string): ChatRequestVariableSet { const contextArr = new ChatRequestVariableSet(); - // get prompt file variables (instructions) and all rereferenced contents first - contextArr.add(... await this.attachmentModel.getPromptFileVariables()); - - // then add all other attachments (includes prompt file variables, but they will be ignored if already added) contextArr.add(...this.attachmentModel.attachments); if (this.implicitContext?.enabled && this.implicitContext.value) { - const implicitChatVariables = await this.implicitContext.toBaseEntries(); + const implicitChatVariables = this.implicitContext.toBaseEntries(); contextArr.add(...implicitChatVariables); } return contextArr; @@ -184,8 +181,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * Check if the chat input part has any prompt file attachments. */ get hasPromptFileAttachments(): boolean { - // if prompt attached explicitly as a "prompt" attachment - return this._attachmentModel.hasPromptFiles(PROMPT_LANGUAGE_ID); + return this._attachmentModel.attachments.some(entry => { + return isPromptFileVariableEntry(entry) && entry.isRoot && this.promptsService.getPromptFileType(entry.value) === PromptsType.prompt; + }); } private _indexOfLastAttachedContextDeletedWithKeyboard: number; @@ -361,6 +359,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, @IChatEntitlementService private readonly entitlementService: IChatEntitlementService, @IChatModeService private readonly chatModeService: IChatModeService, + @IPromptsService private readonly promptsService: IPromptsService, ) { super(); this._onDidLoadInputState = this._register(new Emitter()); diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index acb42d186d5..c22e608c43b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -54,7 +54,7 @@ import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js' import { ChatAgentLocation, ChatMode } from '../common/constants.js'; import { ILanguageModelToolsService, IToolData, ToolSet } from '../common/languageModelToolsService.js'; import { type TPromptMetadata } from '../common/promptSyntax/parsers/promptHeader/promptHeader.js'; -import { IMetadata, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { IPromptParserResult, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { handleModeSwitch } from './actions/chatActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions } from './chat.js'; import { ChatAccessibilityProvider } from './chatAccessibilityProvider.js'; @@ -69,6 +69,7 @@ import { ChatViewWelcomePart } from './viewsWelcome/chatViewWelcomeController.js import { MicrotaskDelay } from '../../../../base/common/symbols.js'; import { IChatRequestVariableEntry, ChatRequestVariableSet as ChatRequestVariableSet, isPromptFileVariableEntry, toPromptFileVariableEntry } from '../common/chatVariableEntries.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; const $ = dom.$; @@ -1207,24 +1208,24 @@ export class ChatWidget extends Disposable implements IChatWidget { private _findPromptFileInContext(attachedContext: ChatRequestVariableSet): URI | undefined { for (const item of attachedContext.asArray()) { - if (isPromptFileVariableEntry(item) && item.isRoot) { + if (isPromptFileVariableEntry(item) && item.isRoot && this.promptsService.getPromptFileType(item.value) === PromptsType.prompt) { return IChatRequestVariableEntry.toUri(item); } } return undefined; } - private async _applyPromptFileIfSet(requestInput: { input: string; attachedContext: ChatRequestVariableSet }): Promise { + private async _applyPromptFileIfSet(requestInput: { input: string; attachedContext: ChatRequestVariableSet }): Promise { - let metadata: IMetadata | undefined; + let parseResult: IPromptParserResult | undefined; // first check if the input has a prompt slash command const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart); if (agentSlashPromptPart) { - metadata = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand); - if (metadata) { + parseResult = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand, CancellationToken.None); + if (parseResult) { // add the prompt file to the context, but not sticky - requestInput.attachedContext.add(toPromptFileVariableEntry(metadata.uri, true)); + requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, true)); // remove the slash command from the input requestInput.input = this.parsedInput.parts.filter(part => !(part instanceof ChatRequestSlashPromptPart)).map(part => part.text).join('').trim(); @@ -1233,25 +1234,25 @@ export class ChatWidget extends Disposable implements IChatWidget { // if not, check if the context contains a prompt file: This is the old workflow that we still support for legacy reasons const uri = this._findPromptFileInContext(requestInput.attachedContext); if (uri) { - metadata = await this.promptsService.getMetadata(uri); + parseResult = await this.promptsService.parse(uri, CancellationToken.None); } } - if (!metadata) { + if (!parseResult) { return undefined; } if (!requestInput.input.trim()) { // NOTE this is a prompt and therefore not localized - requestInput.input = `Follow instructions from ${basename(metadata.uri)}`; + requestInput.input = `Follow instructions in [${basename(parseResult.uri)}](${parseResult.uri.toString()} )`; } - const meta = metadata.metadata; + const meta = parseResult.metadata; if (meta?.promptType === PromptsType.prompt) { await this._applyPromptMetadata(meta); } - return metadata; + return parseResult; } private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise { @@ -1277,15 +1278,17 @@ export class ChatWidget extends Disposable implements IChatWidget { const requestId = this.chatAccessibilityService.acceptRequest(); const requestInputs = { input: !query ? editorValue : query.query, - attachedContext: await this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId), + attachedContext: this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId), }; const isUserQuery = !query; const instructionsEnabled = PromptsConfig.enabled(this.configurationService); if (instructionsEnabled) { + // process the prompt command await this._applyPromptFileIfSet(requestInputs); - await this.autoAttachInstructions(requestInputs.attachedContext); + await this._autoAttachInstructions(requestInputs.attachedContext); + await this._collectReferencedInstructions(requestInputs.attachedContext); } if (this.viewOptions.enableWorkingSet !== undefined && this.input.currentMode === ChatMode.Edit && !this.chatService.edits2Enabled) { @@ -1614,22 +1617,46 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.selectedToolsModel.enable(enabledToolSets, enabledTools, true); } + private async _collectReferencedInstructions(attachedContext: ChatRequestVariableSet): Promise { + for (const variable of attachedContext.asArray()) { + if (isPromptFileVariableEntry(variable)) { + const result = await this.promptsService.parse(variable.value, CancellationToken.None); + for (const ref of result.allValidReferences) { + const reason = localize('instruction.file.reason.referenced', 'Referenced by {0}', basename(variable.value)); + attachedContext.add(toPromptFileVariableEntry(ref, true, reason)); + } + } + } + } + /** - * Resolves instructions that have `include` metadata that can + * Resolves instructions that have `applyTo` metadata that can * match file references in the attached context and then attaches * such instructions to the context. */ - private async autoAttachInstructions(attachedContext: ChatRequestVariableSet): Promise { + private async _autoAttachInstructions(attachedContext: ChatRequestVariableSet): Promise { + const existingInstructions = new ResourceSet(); + const fileInContext = []; - const variableUris = attachedContext.asArray().map(IChatRequestVariableEntry.toUri).filter(isDefined); + for (const variable of attachedContext.asArray()) { + if (isPromptFileVariableEntry(variable)) { + existingInstructions.add(variable.value); + } else { + const uri = IChatRequestVariableEntry.toUri(variable); + if (uri) { + fileInContext.push(uri); + } + } + } - const automaticInstructions = await this.promptsService.findInstructionFilesFor(variableUris); + const automaticInstructions = await this.promptsService.findInstructionFilesFor(fileInContext, existingInstructions); + const promptVariableEntries = automaticInstructions.map(instruction => toPromptFileVariableEntry(instruction.uri, true, instruction.reason)); // add instructions to the final context list - attachedContext.add(...automaticInstructions.map(instruction => toPromptFileVariableEntry(instruction, true))); + attachedContext.add(...promptVariableEntries); // add to attached list to make the instructions sticky - this.inputPart.attachmentModel.addPromptFiles(automaticInstructions); + this.inputPart.attachmentModel.addContext(...promptVariableEntries); } } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts index 6ede7942f8f..0bd58c470f7 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatImplicitContext.ts @@ -259,7 +259,7 @@ export class ChatImplicitContext extends Disposable implements IChatRequestImpli this._onDidChangeValue.fire(); } - public async toBaseEntries(): Promise { + public toBaseEntries(): IChatRequestVariableEntry[] { return [{ kind: 'file', id: this.id, diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts index 93ccc2e8544..3c453f0a528 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts @@ -118,7 +118,7 @@ class AttachInstructionsAction extends Action2 { } if (skipSelectionDialog && resource) { - widget.attachmentModel.addPromptFiles([resource]); + widget.attachmentModel.addContext(toPromptFileVariableEntry(resource, true)); widget.focusInput(); return; } @@ -131,7 +131,7 @@ class AttachInstructionsAction extends Action2 { const result = await pickers.selectPromptFile({ resource, placeholder, type: PromptsType.instructions }); if (result !== undefined) { - widget.attachmentModel.addPromptFiles([result.promptFile]); + widget.attachmentModel.addContext(toPromptFileVariableEntry(result.promptFile, true)); widget.focusInput(); } } diff --git a/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts index 530a3530850..2aa7e1ff65f 100644 --- a/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/chatVariableEntries.ts @@ -185,6 +185,7 @@ export interface IPromptFileVariableEntry extends IBaseChatRequestVariableEntry readonly kind: 'promptFile'; readonly value: URI; readonly isRoot: boolean; + readonly originLabel?: string; readonly modelDescription: string; } @@ -272,7 +273,7 @@ export function isSCMHistoryItemVariableEntry(obj: IChatRequestVariableEntry): o * @param isRoot If the reference is the root reference in the references tree. * This object most likely was explicitly attached by the user. */ -export function toPromptFileVariableEntry(uri: URI, isRoot: boolean): IPromptFileVariableEntry { +export function toPromptFileVariableEntry(uri: URI, isRoot: boolean, originLabel?: string): IPromptFileVariableEntry { return { // `id` for all `prompt files` starts with the well-defined part that the copilot extension(or other chatbot) can rely on id: `vscode.prompt.instructions${isRoot ? '.root' : ''}}__${uri.toString()}`, @@ -281,29 +282,41 @@ export function toPromptFileVariableEntry(uri: URI, isRoot: boolean): IPromptFil kind: 'promptFile', modelDescription: 'Prompt instructions file', isRoot, + originLabel, }; } export class ChatRequestVariableSet { - private _entries = new Map(); + private _ids = new Set(); + private _entries: IChatRequestVariableEntry[] = []; + public add(...entry: IChatRequestVariableEntry[]): void { for (const e of entry) { - if (!this._entries.has(e.id)) { - this._entries.set(e.id, e); + if (!this._ids.has(e.id)) { + this._ids.add(e.id); + this._entries.push(e); } } } + public insertFirst(entry: IChatRequestVariableEntry): void { + if (!this._ids.has(entry.id)) { + this._ids.add(entry.id); + this._entries.unshift(entry); + } + } + public remove(entry: IChatRequestVariableEntry): void { - this._entries.delete(entry.id); + this._ids.delete(entry.id); + this._entries = this._entries.filter(e => e.id !== entry.id); } public has(entry: IChatRequestVariableEntry): boolean { - return this._entries.has(entry.id); + return this._ids.has(entry.id); } public asArray(): IChatRequestVariableEntry[] { - return Array.from(this._entries.values()); + return this._entries.slice(0); // return a copy } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index db6bc946c57..22e743cfd70 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TTree } from '../utils/treeUtils.js'; import { ChatMode } from '../../constants.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Event } from '../../../../../../base/common/event.js'; @@ -15,6 +14,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { PromptsType } from '../promptTypes.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ITopError } from '../parsers/types.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; /** * Provides prompt services. @@ -71,7 +71,7 @@ export interface IMetadata { /** * List of metadata for each valid child prompt reference. */ - readonly children?: readonly TTree[]; + readonly children?: readonly IMetadata[]; } export interface ICustomChatMode { @@ -173,7 +173,7 @@ export interface IPromptsService extends IDisposable { /** * Gets the prompt file for a slash command. */ - resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise; + resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise; /** * Returns a prompt command if the command name is valid. @@ -184,7 +184,7 @@ export interface IPromptsService extends IDisposable { * Find all instruction files which have a glob pattern in their * 'applyTo' metadata record that match the provided list of files. */ - findInstructionFilesFor(fileUris: readonly URI[]): Promise; + findInstructionFilesFor(fileUris: readonly URI[], ignoreInstructions?: ResourceSet): Promise; /** * Event that is triggered when the list of custom chat modes changes. @@ -196,11 +196,6 @@ export interface IPromptsService extends IDisposable { */ getCustomChatModes(token: CancellationToken): Promise; - /** - * Gets the metadata for the given prompt file uri. - */ - getMetadata(promptFileUri: URI): Promise; - /** * Get all metadata for entire prompt references tree * that spans out of each of the provided files. @@ -216,6 +211,12 @@ export interface IPromptsService extends IDisposable { * @param uris */ parse(uri: URI, token: CancellationToken): Promise; + + /** + * Returns the prompt file type for the given URI. + * @param resource the URI of the resource + */ + getPromptFileType(resource: URI): PromptsType | undefined; } export interface IChatPromptSlashCommand { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 31f3b1d8293..9f9acd0a867 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { flatten } from '../utils/treeUtils.js'; import { localize } from '../../../../../../nls.js'; -import { isValidPromptType, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js'; +import { getPromptsTypeForLanguageId, isValidPromptType, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js'; import { PromptParser } from '../parsers/promptParser.js'; import { match, splitGlobAware } from '../../../../../../base/common/glob.js'; import { type URI } from '../../../../../../base/common/uri.js'; @@ -27,6 +26,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import type { IChatPromptSlashCommand, ICustomChatMode, IMetadata, IPromptParserResult, IPromptPath, IPromptsService, TPromptsStorage } from './promptsService.js'; import { getCleanPromptName, PROMPT_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; /** * Provides prompt services. @@ -56,6 +56,7 @@ export class PromptsService extends Disposable implements IPromptsService { @IModelService private readonly modelService: IModelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IUserDataProfileService private readonly userDataService: IUserDataProfileService, + @ILanguageService private readonly languageService: ILanguageService, ) { super(); @@ -102,6 +103,12 @@ export class PromptsService extends Disposable implements IPromptsService { return this.onDidChangeCustomChatModesEvent; } + public getPromptFileType(uri: URI): PromptsType | undefined { + const model = this.modelService.getModel(uri); + const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri); + return languageId ? getPromptsTypeForLanguageId(languageId) : undefined; + } + /** * @throws {Error} if: @@ -152,12 +159,12 @@ export class PromptsService extends Disposable implements IPromptsService { return undefined; } - public async resolvePromptSlashCommand(data: IChatPromptSlashCommand): Promise { + public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise { const promptUri = await this.getPromptPath(data); if (!promptUri) { return undefined; } - return await this.getMetadata(promptUri); + return await this.parse(promptUri, token); } private async getPromptPath(data: IChatPromptSlashCommand): Promise { @@ -248,31 +255,34 @@ export class PromptsService extends Disposable implements IPromptsService { } - public async findInstructionFilesFor(files: readonly URI[]): Promise { + public async findInstructionFilesFor(files: readonly URI[], ignoreInstructions?: ResourceSet): Promise { const instructionFiles = await this.listPromptFiles(PromptsType.instructions, CancellationToken.None); if (instructionFiles.length === 0) { return []; } - const instructions = await this.getAllMetadata( - instructionFiles.map(file => file.uri), - ); - + const result: { uri: URI; reason: string }[] = []; const foundFiles = new ResourceSet(); - for (const instruction of instructions.flatMap(flatten)) { - const { metadata, uri } = instruction; + for (const instructionFile of instructionFiles) { + const { metadata, uri } = await this.parse(instructionFile.uri, CancellationToken.None); if (metadata?.promptType !== PromptsType.instructions) { continue; } + if (ignoreInstructions?.has(uri) || foundFiles.has(uri)) { + // the instruction file is already part of the input or has already been processed + continue; + } + + const { applyTo } = metadata; if (applyTo === undefined) { continue; } const patterns = splitGlobAware(applyTo, ','); - const patterMatches = (pattern: string) => { + const patterMatches = (pattern: string): URI | true | false => { pattern = pattern.trim(); if (pattern.length === 0) { // if glob pattern is empty, skip it @@ -293,22 +303,29 @@ export class PromptsService extends Disposable implements IPromptsService { for (const file of files) { // if the file is not a valid URI, skip it if (match(pattern, file.path)) { - return true; + return file; } } return false; }; - if (patterns.some(patterMatches)) { - foundFiles.add(uri); - } - } - return [...foundFiles]; - } - public async getMetadata(promptFileUri: URI): Promise { - const metaDatas = await this.getAllMetadata([promptFileUri]); - return metaDatas[0]; + for (const pattern of patterns) { + const matchResult = patterMatches(pattern); + if (matchResult !== false) { + const reason = matchResult === true ? + localize('instruction.file.reason.allFiles', 'Automatically attached as pattern is **') : + localize('instruction.file.reason.specificFile', 'Automatically attached as pattern {0} matches {1}', applyTo, this.labelService.getUriLabel(matchResult, { relative: true })); + + result.push({ uri, reason }); + foundFiles.add(uri); + break; + } + } + + + } + return result; } public async getAllMetadata(promptUris: readonly URI[]): Promise { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/treeUtils.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/treeUtils.ts deleted file mode 100644 index b050a59a387..00000000000 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/treeUtils.ts +++ /dev/null @@ -1,278 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { assert } from '../../../../../../base/common/assert.js'; - -/** - * Type for a generic tree node. - */ -export type TTree = { children?: readonly TTree[] } & TTreenNode; - -/** - * Flatter a tree structure into a single flat array. - */ -export function flatten(treeRoot: TTree): TTreeNode[] { - const result: TTreeNode[] = []; - - result.push(treeRoot); - - for (const child of treeRoot.children ?? []) { - result.push(...flatten(child)); - } - - return result; -} - -/** - * Traverse a tree structure and execute a callback for each node. - */ -export function forEach(callback: (node: TTreeNode) => boolean, treeRoot: TTree): ReturnType { - const shouldStop = callback(treeRoot); - - if (shouldStop === true) { - return true; - } - - for (const child of treeRoot.children ?? []) { - const childShouldStop = forEach(callback, child); - - if (childShouldStop === true) { - return true; - } - } - - return false; -} - -/** - * Maps nodes of a tree to a new type preserving the original tree structure by invoking - * the provided callback function for each node. - * - * @param callback Function to map each of the nodes in the tree. The callback receives the original - * readonly tree node and a list of its already-mapped readonly children and expected - * to return a new tree node object. If the new object does not have an explicit - * `children` property set (e.g., set to `undefined` or an array), the utility will - * automatically set the `children` property to the `new mapped children` for you, - * otherwise the set `children` property is preserved. Likewise, if the callback - * modifies the `newChildren` array directly, but doesn't explicitly set the `children` - * property on the returned object, the modification to the `newChildren` array are - * preserved in the resulting object. - * - * @param treeRoot The root node of the tree to be mapped. - * - * ### Examples - * - * ```typescript - * const tree = { - * id: '1', - * children: [ - * { id: '1.1' }, - * { id: '1.2' }, - * }; - * - * const newTree = map((node, _newChildren) => { - * return { - * name: `name-of-${node.id}`, - * }; - * }, tree); - * - * assert.deepStrictEqual(newTree, { - * name: 'name-of-1', - * children: [ - * { name: 'name-of-1.1' }, - * { name: 'name-of-1.2' }, - * }); - * ``` - */ -export function map< - TTreeNode extends object, - TNewTreeNode extends object ->( - callback: ( - originalNode: Readonly>, - newChildren: Readonly[] | undefined, - ) => TTree, - treeRoot: TTree, -): TTree { - // if the node does not have children, just call the callback - if (treeRoot.children === undefined) { - return callback(treeRoot, undefined); - } - - // otherwise process all the children recursively first - const newChildren = treeRoot.children - .map(curry(map, callback)); - - // then run the callback with the new children - const newNode = callback(treeRoot, newChildren); - - // if user explicitly set the children, preserve the value - if ('children' in newNode) { - return newNode; - } - - // otherwise if no children is explicitly set, - // use the new children array instead - newNode.children = newChildren; - - return newNode; -} - -/** - * Type for a generic comparable object - the one that implements - * the `equals` method that allows to compare it with similar objects. - */ -type TComparable = T & { equals: (other: T) => boolean }; - -/** - * Type for a diff object that represents a difference between - * a pair of objects. See {@link difference} utility and related - * {@link TDifference} for more info. - */ -type TDiff = { - /** - * Reference to the first object that was used during - * comparison. Equal to an object of the first tree - * parameter passed to {@link difference}. When set to - * `null`, then the object was missing in the first tree, - * was but present in the second tree. - */ - readonly object1: TObject1; - - /** - * Reference to the second object that was used during - * comparison. Equal to an object of the second tree - * parameter passed to {@link difference}. When set to - * `null`, then the object was missing in the second tree, - * was but present in the first tree. - */ - readonly object2: TObject2; -}; - -/** - * Type for a diff object that represents a difference between - * a pair of objects of the same type. - * See {@link difference} utility for more info. - * - * The type is on-purpose constrained as only one of the object - * references can have the `null` reference but never both of - * them at the same time. This is due to the fact that two `null` - * values would indicate that both objects were missing during - * comparison, which does not make sense in this context. - */ -type TDifference = TDiff | TDiff; - -/** - * Type for a tree of differences between two trees. - * See {@link difference} utility for more info. - */ -type TDiffTree = TTree & { - /** - * Index inside the parent's tree node 'children' array - * reflecting the position of the object pair that was - * compared. Always equal to `0` for a difference at - * the root node level of a tree. - */ - readonly index: number; -}>; - -/** - * Utility to find a difference between two provided trees - * of the same type. The result is another tree of difference - * nodes that represent difference between tree node pairs. - */ -export function difference>(tree1: TTree>, tree2: TTree>): TDiffTree | null { - const tree1Children = tree1.children ?? []; - const tree2Children = tree2.children ?? []; - - // if there are no children in the both trees left anymore, - // compare the nodes directly themselves and return the result - if (tree1Children.length === 0 && tree2Children.length === 0) { - if (tree1.equals(tree2)) { - return null; - } - - return { - index: 0, - object1: tree1, - object2: tree2, - }; - } - - // with children present, iterate over them to find difference for each pair - const maxChildren = Math.max(tree1Children.length, tree2Children.length); - const children: TDiffTree[] = []; - for (let i = 0; i < maxChildren; i++) { - const child1 = tree1Children[i]; - const child2 = tree2Children[i]; - - // sanity check to ensure that at least one of the children is defined - // as otherwise this case most likely indicates a logic error or a bug - assert( - (child1 !== undefined) || (child2 !== undefined), - 'At least one of the children must be defined.', - ); - - // if one of the children is missing, report it as a difference - if ((child1 === undefined) || (child2 === undefined)) { - children.push({ - index: i, - object1: child1 ?? null, - object2: child2 ?? null, - }); - - continue; - } - - const diff = difference(child1, child2); - if (diff === null) { - continue; - } - - children.push({ - ...diff, - index: i, - }); - } - - // if there some children that are different, report them - if (children.length !== 0) { - return { - index: 0, - object1: tree1, - object2: tree2, - children, - }; - } - - // there is no children difference, nor differences in the nodes - // themselves, hence return explicit `null` value to indicate that - return null; -} - -/** - * Type for a rest parameters of function, excluding - * the first argument. - */ -type TRestParameters unknown> = - T extends (first: Parameters[0], ...rest: infer R) => unknown ? R : never; - -/** - * Type for a curried function. - * See {@link curry} for more info. - */ -type TCurriedFunction unknown> = ((...args: TRestParameters) => ReturnType); - -/** - * Curry a provided function with the first argument. - */ -export function curry( - callback: (arg1: T, ...args: any[]) => K, - arg1: T, -): TCurriedFunction { - return (...args) => { - return callback(arg1, ...args); - }; -} diff --git a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts index 810e953918e..72edf372708 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts @@ -18,9 +18,6 @@ export class MockPromptsService implements IPromptsService { getAllMetadata(_files: readonly URI[]): Promise { throw new Error('Method not implemented.'); } - getMetadata(_file: URI): Promise { - throw new Error('Method not implemented.'); - } getSyntaxParserFor(_model: ITextModel): TextModelPromptParser & { isDisposed: false } { throw new Error('Method not implemented.'); } @@ -33,13 +30,13 @@ export class MockPromptsService implements IPromptsService { asPromptSlashCommand(command: string): IChatPromptSlashCommand | undefined { return undefined; } - resolvePromptSlashCommand(_data: IChatPromptSlashCommand): Promise { + resolvePromptSlashCommand(_data: IChatPromptSlashCommand, _token: CancellationToken): Promise { throw new Error('Method not implemented.'); } findPromptSlashCommands(): Promise { throw new Error('Method not implemented.'); } - findInstructionFilesFor(_files: readonly URI[]): Promise { + findInstructionFilesFor(_files: readonly URI[]): Promise { throw new Error('Method not implemented.'); } onDidChangeCustomChatModes: Event = Event.None; @@ -49,5 +46,8 @@ export class MockPromptsService implements IPromptsService { parse(uri: URI, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + getPromptFileType(resource: URI): PromptsType | undefined { + throw new Error('Method not implemented.'); + } dispose(): void { } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index a3d8385568a..e3f50cfdb29 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -30,6 +30,7 @@ import { IPromptFileReference } from '../../../../common/promptSyntax/parsers/ty import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockFilesystem } from '../testUtils/mockFilesystem.js'; +import { ILabelService } from '../../../../../../../platform/label/common/label.js'; /** * Helper class to assert the properties of a link. @@ -126,6 +127,7 @@ suite('PromptsService', () => { return 'plaintext'; } }); + instaService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path }); const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); disposables.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); @@ -874,7 +876,7 @@ suite('PromptsService', () => { ]); assert.deepStrictEqual( - instructions.map(i => i.path), + instructions.map(i => i.uri.path), [ // local instructions URI.joinPath(rootFolderUri, '.github/prompts/file1.instructions.md').path, @@ -1055,7 +1057,7 @@ suite('PromptsService', () => { ]); assert.deepStrictEqual( - instructions.map(i => i.path), + instructions.map(i => i.uri.path), [ // local instructions URI.joinPath(rootFolderUri, '.github/prompts/file1.instructions.md').path, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/treeUtils.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/treeUtils.test.ts deleted file mode 100644 index 38d070a4d91..00000000000 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/treeUtils.test.ts +++ /dev/null @@ -1,681 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { randomInt } from '../../../../../../../base/common/numbers.js'; -import { Range } from '../../../../../../../editor/common/core/range.js'; -import { BaseToken } from '../../../../common/promptSyntax/codecs/base/baseToken.js'; -import { CompositeToken } from '../../../../common/promptSyntax/codecs/base/compositeToken.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { curry, difference, flatten, forEach, map, TTree } from '../../../../common/promptSyntax/utils/treeUtils.js'; -import { ExclamationMark, Space, Tab, VerticalTab, Word } from '../../../../common/promptSyntax/codecs/base/simpleCodec/tokens/tokens.js'; - -suite('tree utilities', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('flatten', () => { - const tree = { - id: '1', - children: [ - { - id: '1.1', - }, - { - id: '1.2', - children: [ - { - id: '1.2.1', - children: [ - { - id: '1.2.1.1', - }, - { - id: '1.2.1.2', - }, - { - id: '1.2.1.3', - } - ], - }, - { - id: '1.2.2', - }, - ] - }, - ], - }; - - assert.deepStrictEqual(flatten(tree), [ - tree, - tree.children[0], - tree.children[1], - tree.children[1].children![0], - tree.children[1].children![0].children![0], - tree.children[1].children![0].children![1], - tree.children[1].children![0].children![2], - tree.children[1].children![1], - ]); - - assert.deepStrictEqual(flatten({}), [{}]); - }); - - suite('forEach', () => { - test('iterates though all nodes', () => { - const tree = { - id: '1', - children: [ - { - id: '1.1', - }, - { - id: '1.2', - children: [ - { - id: '1.2.1', - children: [ - { - id: '1.2.1.1', - }, - { - id: '1.2.1.2', - }, - { - id: '1.2.1.3', - } - ], - }, - { - id: '1.2.2', - }, - ] - }, - ], - }; - - const treeCopy = JSON.parse(JSON.stringify(tree)); - - const seenIds: string[] = []; - forEach((node) => { - seenIds.push(node.id); - return false; - }, tree); - - assert.deepStrictEqual(seenIds, [ - '1', - '1.1', - '1.2', - '1.2.1', - '1.2.1.1', - '1.2.1.2', - '1.2.1.3', - '1.2.2', - ]); - - assert.deepStrictEqual( - treeCopy, - tree, - 'forEach should not modify the tree', - ); - }); - - test('can be stopped prematurely', () => { - const tree = { - id: '1', - children: [ - { - id: '1.1', - }, - { - id: '1.2', - children: [ - { - id: '1.2.1', - children: [ - { - id: '1.2.1.1', - }, - { - id: '1.2.1.2', - }, - { - id: '1.2.1.3', - children: [ - { - id: '1.2.1.3.1', - }, - ], - } - ], - }, - { - id: '1.2.2', - }, - ] - }, - ], - }; - - const treeCopy = JSON.parse(JSON.stringify(tree)); - - const seenIds: string[] = []; - forEach((node) => { - seenIds.push(node.id); - - if (node.id === '1.2.1') { - return true; // stop traversing - } - - return false; - }, tree); - - assert.deepStrictEqual(seenIds, [ - '1', - '1.1', - '1.2', - '1.2.1', - ]); - - assert.deepStrictEqual( - treeCopy, - tree, - 'forEach should not modify the tree', - ); - }); - }); - - suite('map', () => { - test('maps a tree', () => { - interface ITree { - id: string; - children?: ITree[]; - } - - const tree: ITree = { - id: '1', - children: [ - { - id: '1.1', - }, - { - id: '1.2', - children: [ - { - id: '1.2.1', - children: [ - { - id: '1.2.1.1', - }, - { - id: '1.2.1.2', - }, - { - id: '1.2.1.3', - } - ], - }, - { - id: '1.2.2', - }, - ] - }, - ], - }; - - const treeCopy = JSON.parse(JSON.stringify(tree)); - - const newRootNode = { - newId: '__1__', - }; - - const newChildNode = { - newId: '__1.2.1.3__', - }; - - const newTree = map((node) => { - if (node.id === '1') { - return newRootNode; - } - - if (node.id === '1.2.1.3') { - return newChildNode; - } - - return { - newId: `__${node.id}__`, - }; - }, tree); - - assert.deepStrictEqual(newTree, { - newId: '__1__', - children: [ - { - newId: '__1.1__', - }, - { - newId: '__1.2__', - children: [ - { - newId: '__1.2.1__', - children: [ - { - newId: '__1.2.1.1__', - }, - { - newId: '__1.2.1.2__', - }, - { - newId: '__1.2.1.3__', - }, - ], - }, - { - newId: '__1.2.2__', - }, - ] - }, - ], - }); - - assert( - newRootNode === newTree, - 'Map should not replace return node reference (root node).', - ); - - assert( - newChildNode === newTree.children![1].children![0].children![2], - 'Map should not replace return node reference (child node).', - ); - - assert.deepStrictEqual( - treeCopy, - tree, - 'forEach should not modify the tree', - ); - }); - - test('callback can control resulting children', () => { - interface ITree { - id: string; - children?: ITree[]; - } - - const tree: ITree = { - id: '1', - children: [ - { id: '1.1' }, - { - id: '1.2', - children: [ - { - id: '1.2.1', - children: [ - { id: '1.2.1.1' }, - { id: '1.2.1.2' }, - { - id: '1.2.1.3', - children: [ - { - id: '1.2.1.3.1', - }, - { - id: '1.2.1.3.2', - }, - ], - } - ], - }, - { - id: '1.2.2', - children: [ - { id: '1.2.2.1' }, - { id: '1.2.2.2' }, - { id: '1.2.2.3' }, - ], - }, - { - id: '1.2.3', - children: [ - { id: '1.2.3.1' }, - { id: '1.2.3.2' }, - { id: '1.2.3.3' }, - { id: '1.2.3.4' }, - ], - }, - ] - }, - ], - }; - - const treeCopy = JSON.parse(JSON.stringify(tree)); - - const newNodeWithoutChildren = { - newId: '__1.2.1.3__', - children: undefined, - }; - - const newTree = map((node, newChildren) => { - // validates that explicitly setting `children` to - // `undefined` will be preserved on the resulting new node - if (node.id === '1.2.1.3') { - return newNodeWithoutChildren; - } - - // validates that setting `children` to a new array - // will be preserved on the resulting new node - if (node.id === '1.2.2') { - assert.deepStrictEqual( - newChildren, - [ - { newId: '__1.2.2.1__' }, - { newId: '__1.2.2.2__' }, - { newId: '__1.2.2.3__' }, - ], - `Node '${node.id}' must have correct new children.`, - ); - - return { - newId: `__${node.id}__`, - children: [newChildren[2]], - }; - } - - // validates that modifying `newChildren` directly - // will be preserved on the resulting new node - if (node.id === '1.2.3') { - assert.deepStrictEqual( - newChildren, - [ - { newId: '__1.2.3.1__' }, - { newId: '__1.2.3.2__' }, - { newId: '__1.2.3.3__' }, - { newId: '__1.2.3.4__' }, - ], - `Node '${node.id}' must have correct new children.`, - ); - - newChildren.length = 2; - - return { - newId: `__${node.id}__`, - }; - } - - // convert to a new node in all other cases - return { - newId: `__${node.id}__`, - }; - }, tree); - - assert.deepStrictEqual(newTree, { - newId: '__1__', - children: [ - { newId: '__1.1__' }, - { - newId: '__1.2__', - children: [ - { - newId: '__1.2.1__', - children: [ - { newId: '__1.2.1.1__' }, - { newId: '__1.2.1.2__' }, - { - newId: '__1.2.1.3__', - children: undefined, - }, - ], - }, - { - newId: '__1.2.2__', - children: [ - { newId: '__1.2.2.3__' }, - ], - }, - { - newId: '__1.2.3__', - children: [ - { newId: '__1.2.3.1__' }, - { newId: '__1.2.3.2__' }, - ], - }, - ] - }, - ], - }); - - assert( - newNodeWithoutChildren === newTree.children![1].children![0].children![2], - 'Map should not replace return node reference (node without children).', - ); - - assert.deepStrictEqual( - treeCopy, - tree, - 'forEach should not modify the tree', - ); - }); - }); - - test('curry', () => { - const originalFunction = (a: number, b: number, c: number) => { - return a + b + c; - }; - - const firstArgument = randomInt(100, -100); - const curriedFunction = curry(originalFunction, firstArgument); - - let iterations = 10; - while (iterations-- > 0) { - const secondArgument = randomInt(100, -100); - const thirdArgument = randomInt(100, -100); - - assert.strictEqual( - curriedFunction(secondArgument, thirdArgument), - originalFunction(firstArgument, secondArgument, thirdArgument), - 'Curried and original functions must yield the same result.', - ); - - // a sanity check to ensure we don't compare ambiguous infinities - assert( - isFinite(originalFunction(firstArgument, secondArgument, thirdArgument)), - 'Function results must be finite.', - ); - } - }); - - suite('difference', () => { - class TestCompositeToken extends CompositeToken> { - public override toString(): string { - return `CompositeToken:\n${BaseToken.render(this.children, '\n')})`; - } - } - - - test('tree roots differ (no children)', () => { - const tree1 = new Word(new Range(1, 1, 1, 1 + 5), 'hello'); - const tree2 = new Word(new Range(1, 1, 1, 1 + 5), 'halou'); - - assert.deepStrictEqual( - difference(tree1, tree2), - { - index: 0, - object1: tree1, - object2: tree2, - }, - 'Unexpected difference between token trees.', - ); - }); - - test('returns tree difference (single children level)', () => { - const tree1 = asTreeNode>( - new Word(new Range(1, 1, 1, 1 + 5), 'hello'), - [ - new Space(new Range(1, 6, 1, 7)), - new Word(new Range(1, 7, 1, 7 + 5), 'world'), - ], - ); - - const tree2 = asTreeNode>( - new Word(new Range(1, 1, 1, 1 + 5), 'hello'), - [ - new Space(new Range(1, 6, 1, 7)), - new Word(new Range(1, 7, 1, 7 + 6), 'world!'), - ], - ); - - assert.deepStrictEqual( - difference(tree1, tree2), - { - index: 0, - object1: tree1, - object2: tree2, - children: [ - { - index: 1, - object1: new Word( - new Range(1, 7, 1, 7 + 5), - 'world', - ), - object2: new Word( - new Range(1, 7, 1, 7 + 6), - 'world!', - ), - } - ], - }, - 'Unexpected difference between token trees.', - ); - }); - - test('returns tree difference (multiple children levels)', () => { - const compositeToken1 = new TestCompositeToken([ - new VerticalTab(new Range(1, 13, 1, 14)), - new Space(new Range(1, 14, 1, 15)), - new Word(new Range(1, 15, 1, 15 + 5), 'again'), - new ExclamationMark(new Range(1, 20, 1, 21)), - ]); - const tree1: TTree = asTreeNode>( - new Word(new Range(1, 1, 1, 1 + 5), 'hello'), - [ - new Space(new Range(1, 6, 1, 7)), - new Word(new Range(1, 7, 1, 7 + 5), 'world'), - compositeToken1, - ], - ); - - const compositeToken2 = new TestCompositeToken([ - new VerticalTab(new Range(1, 13, 1, 14)), - new Space(new Range(1, 14, 1, 15)), - new Word(new Range(1, 15, 1, 15 + 5), 'again'), - new Tab(new Range(1, 20, 1, 21)), - new ExclamationMark(new Range(1, 21, 1, 22)), - ]); - const tree2: TTree = asTreeNode>( - new Word(new Range(1, 1, 1, 1 + 5), 'hello'), - [ - new Space(new Range(1, 6, 1, 7)), - new Word(new Range(1, 7, 1, 7 + 5), 'world'), - compositeToken2, - ], - ); - - assert.deepStrictEqual( - difference(tree1, tree2), - { - index: 0, - object1: tree1, - object2: tree2, - children: [ - { - index: 2, - object1: compositeToken1, - object2: compositeToken2, - children: [ - { - index: 3, - object1: compositeToken1.children[3], - object2: compositeToken2.children[3], - }, - { - index: 4, - object1: null, - object2: compositeToken2.children[4], - }, - ], - } - ], - }, - 'Unexpected difference between token trees.', - ); - }); - - test('returns null for equal trees', () => { - const tree1 = new TestCompositeToken([ - asTreeNode(new Word( - new Range(1, 1, 1, 1 + 5), - 'hello', - ), []), - asTreeNode(new Space(new Range(1, 6, 1, 7)), []), - asTreeNode(new Word( - new Range(1, 7, 1, 7 + 6), - 'world!', - ), []), - ]); - - const tree2 = new TestCompositeToken([ - asTreeNode(new Word( - new Range(1, 1, 1, 1 + 5), - 'hello', - ), []), - asTreeNode(new Space(new Range(1, 6, 1, 7)), []), - asTreeNode(new Word( - new Range(1, 7, 1, 7 + 6), - 'world!', - ), []), - ]); - - assert.strictEqual( - difference(tree1, tree2), - null, - 'Unexpected difference between token trees.', - ); - - assert.strictEqual( - difference(tree1, tree1), - null, - 'Must be a null difference when compared with itself.', - ); - }); - }); -}); - -/** - * Add provided 'children' list to a given object hence - * allowing the object to be used as a general tree node. - */ -function asTreeNode( - item: T, - children: readonly TTree[], -): TTree { - return new Proxy(item, { - get(target, prop, _receiver) { - if (prop === 'children') { - return children; - } - - // tokens equality uses the 'constructor' property for - // comparison, hence we need to return the original one - if (prop === 'constructor') { - return target.constructor; - } - - const result = Reflect.get(target, prop); - if (typeof result === 'function') { - return result.bind(target); - } - - return result; - }, - }); -} diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts index 21f8f4dc52a..521a1b3466d 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -202,7 +202,7 @@ suite('InlineChatController', function () { [ITextModelService, new SyncDescriptor(TextModelResolverService)], [ILanguageModelToolsService, new SyncDescriptor(MockLanguageModelToolsService)], [IPromptsService, new class extends mock() { - override async findInstructionFilesFor(_file: readonly URI[]): Promise { + override async findInstructionFilesFor(_file: readonly URI[]): Promise { return []; } }],