Skip to content

Commit 5493635

Browse files
authored
Merge pull request microsoft#271187 from microsoft/brchen/prompt-desc
feat: display description of prompt as placeholder text when user types `/{prompt}` in chat input
2 parents 0fbe5c2 + b8c5e29 commit 5493635

File tree

5 files changed

+174
-9
lines changed

5 files changed

+174
-9
lines changed

src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../comm
2222
import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../common/chatColors.js';
2323
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../common/chatParserTypes.js';
2424
import { ChatRequestParser } from '../../common/chatRequestParser.js';
25+
import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
2526
import { IChatWidget } from '../chat.js';
2627
import { ChatWidget } from '../chatWidget.js';
2728
import { dynamicVariableDecorationType } from './chatDynamicVariables.js';
@@ -35,6 +36,10 @@ function agentAndCommandToKey(agent: IChatAgentData, subcommand: string | undefi
3536
return subcommand ? `${agent.id}__${subcommand}` : agent.id;
3637
}
3738

39+
function isWhitespaceOrPromptPart(p: IParsedChatRequestPart): boolean {
40+
return (p instanceof ChatRequestTextPart && !p.text.trim().length) || (p instanceof ChatRequestSlashPromptPart);
41+
}
42+
3843
class InputEditorDecorations extends Disposable {
3944

4045
public readonly id = 'inputEditorDecorations';
@@ -50,6 +55,7 @@ class InputEditorDecorations extends Disposable {
5055
@IChatAgentService private readonly chatAgentService: IChatAgentService,
5156
@IConfigurationService private readonly configurationService: IConfigurationService,
5257
@ILabelService private readonly labelService: ILabelService,
58+
@IPromptsService private readonly promptsService: IPromptsService,
5359
) {
5460
super();
5561

@@ -69,6 +75,7 @@ class InputEditorDecorations extends Disposable {
6975
this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent, e.slashCommand?.name));
7076
}));
7177
this._register(this.chatAgentService.onDidChangeAgents(() => this.updateInputEditorDecorations()));
78+
this._register(this.promptsService.onDidChangeParsedPromptFilesCache(() => this.updateInputEditorDecorations()));
7279
this._register(autorun(reader => {
7380
// Watch for changes to the current mode and its properties
7481
const currentMode = this.widget.input.currentModeObs.read(reader);
@@ -123,6 +130,8 @@ class InputEditorDecorations extends Disposable {
123130
return transparentForeground?.toString();
124131
}
125132

133+
134+
126135
private async updateInputEditorDecorations() {
127136
const inputValue = this.widget.inputEditor.getValue();
128137

@@ -235,6 +244,26 @@ class InputEditorDecorations extends Disposable {
235244
}
236245
}
237246

247+
const onlyPromptCommandAndWhitespace = slashPromptPart && parsedRequest.every(isWhitespaceOrPromptPart);
248+
if (onlyPromptCommandAndWhitespace && exactlyOneSpaceAfterPart(slashPromptPart)) {
249+
// Prompt slash command with no other text - show the placeholder
250+
// Resolve the prompt file (this will use cache if available)
251+
const promptFile = this.promptsService.resolvePromptSlashCommandFromCache(slashPromptPart.slashPromptCommand.command);
252+
253+
const description = promptFile?.header?.description;
254+
if (description) {
255+
placeholderDecoration = [{
256+
range: getRangeForPlaceholder(slashPromptPart),
257+
renderOptions: {
258+
after: {
259+
contentText: description,
260+
color: this.getPlaceholderColor(),
261+
}
262+
}
263+
}];
264+
}
265+
}
266+
238267
this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, placeholderDecoration ?? []);
239268

240269
const textDecorations: IDecorationOptions[] | undefined = [];

src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ export interface IPromptsService extends IDisposable {
167167
*/
168168
resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise<ParsedPromptFile | undefined>;
169169

170+
/**
171+
* Gets the prompt file for a slash command from cache if available.
172+
* @param command - name of the prompt command without slash
173+
*/
174+
resolvePromptSlashCommandFromCache(command: string): ParsedPromptFile | undefined;
175+
176+
/**
177+
* Event that is triggered when slash command -> ParsedPromptFile cache is updated.
178+
* Event handler can call resolvePromptSlashCommandFromCache in case there is new value populated.
179+
*/
180+
readonly onDidChangeParsedPromptFilesCache: Event<void>;
181+
170182
/**
171183
* Returns a prompt command if the command name is valid.
172184
*/

src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export class PromptsService extends Disposable implements IPromptsService {
5252

5353
private parsedPromptFileCache = new ResourceMap<[number, ParsedPromptFile]>();
5454

55+
/**
56+
* Cache for parsed prompt files keyed by command name.
57+
*/
58+
private promptFileByCommandCache = new Map<string, { value: ParsedPromptFile | undefined; pendingPromise: Promise<ParsedPromptFile | undefined> | undefined }>();
59+
60+
private onDidChangeParsedPromptFilesCacheEmitter = new Emitter<void>();
61+
5562
/**
5663
* Contributed files from extensions keyed by prompt type then name.
5764
*/
@@ -79,8 +86,32 @@ export class PromptsService extends Disposable implements IPromptsService {
7986
) {
8087
super();
8188

89+
this.onDidChangeParsedPromptFilesCacheEmitter = this._register(new Emitter<void>());
90+
8291
this.fileLocator = this._register(this.instantiationService.createInstance(PromptFilesLocator));
8392

93+
const promptUpdateTracker = this._register(new PromptUpdateTracker(this.fileLocator, this.modelService));
94+
this._register(promptUpdateTracker.onDiDPromptChange((event) => {
95+
if (event.kind === 'fileSystem') {
96+
this.promptFileByCommandCache.clear();
97+
}
98+
else {
99+
// Clear cache for prompt files that match the changed URI\
100+
const pendingDeletes: string[] = [];
101+
for (const [key, value] of this.promptFileByCommandCache) {
102+
if (isEqual(value.value?.uri, event.uri)) {
103+
pendingDeletes.push(key);
104+
}
105+
}
106+
107+
for (const key of pendingDeletes) {
108+
this.promptFileByCommandCache.delete(key);
109+
}
110+
}
111+
112+
this.onDidChangeParsedPromptFilesCacheEmitter.fire();
113+
}));
114+
84115
this._register(this.modelService.onModelRemoved((model) => {
85116
this.parsedPromptFileCache.delete(model.uri);
86117
}));
@@ -101,13 +132,16 @@ export class PromptsService extends Disposable implements IPromptsService {
101132
return this.onDidChangeCustomAgentsEmitter.event;
102133
}
103134

135+
public get onDidChangeParsedPromptFilesCache(): Event<void> {
136+
return this.onDidChangeParsedPromptFilesCacheEmitter.event;
137+
}
138+
104139
public getPromptFileType(uri: URI): PromptsType | undefined {
105140
const model = this.modelService.getModel(uri);
106141
const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri);
107142
return languageId ? getPromptsTypeForLanguageId(languageId) : undefined;
108143
}
109144

110-
111145
public getParsedPromptFile(textModel: ITextModel): ParsedPromptFile {
112146
const cached = this.parsedPromptFileCache.get(textModel.uri);
113147
if (cached && cached[0] === textModel.getVersionId()) {
@@ -187,26 +221,56 @@ export class PromptsService extends Disposable implements IPromptsService {
187221
}
188222

189223
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise<ParsedPromptFile | undefined> {
190-
const promptUri = await this.getPromptPath(data);
224+
const promptUri = data.promptPath?.uri ?? await this.getPromptPath(data.command);
191225
if (!promptUri) {
192226
return undefined;
193227
}
228+
194229
try {
195230
return await this.parseNew(promptUri, token);
196231
} catch (error) {
197232
this.logger.error(`[resolvePromptSlashCommand] Failed to parse prompt file: ${promptUri}`, error);
198233
return undefined;
199234
}
235+
}
236+
237+
private async populatePromptCommandCache(command: string): Promise<ParsedPromptFile | undefined> {
238+
let cache = this.promptFileByCommandCache.get(command);
239+
if (cache && cache.pendingPromise) {
240+
return cache.pendingPromise;
241+
}
242+
243+
const newPromise = this.resolvePromptSlashCommand({ command, detail: '' }, CancellationToken.None);
244+
if (cache) {
245+
cache.pendingPromise = newPromise;
246+
}
247+
else {
248+
cache = { value: undefined, pendingPromise: newPromise };
249+
this.promptFileByCommandCache.set(command, cache);
250+
}
251+
252+
const newValue = await newPromise.finally(() => cache.pendingPromise = undefined);
200253

254+
// TODO: consider comparing the newValue and the old and only emit change event when there are value changes
255+
cache.value = newValue;
256+
this.onDidChangeParsedPromptFilesCacheEmitter.fire();
257+
258+
return newValue;
201259
}
202260

203-
private async getPromptPath(data: IChatPromptSlashCommand): Promise<URI | undefined> {
204-
if (data.promptPath) {
205-
return data.promptPath.uri;
261+
public resolvePromptSlashCommandFromCache(command: string): ParsedPromptFile | undefined {
262+
const cache = this.promptFileByCommandCache.get(command);
263+
const value = cache?.value;
264+
if (value === undefined) {
265+
// kick off a async process to refresh the cache while we returns the current cached value
266+
void this.populatePromptCommandCache(command).catch((error) => { });
206267
}
207268

269+
return value;
270+
}
271+
272+
private async getPromptPath(command: string): Promise<URI | undefined> {
208273
const promptPaths = await this.listPromptFiles(PromptsType.prompt, CancellationToken.None);
209-
const command = data.command;
210274
const result = promptPaths.find(promptPath => getCommandNameFromPromptPath(promptPath) === command);
211275
if (result) {
212276
return result.uri;
@@ -443,7 +507,63 @@ export class UpdateTracker extends Disposable {
443507
this.listeners.forEach(listener => listener.dispose());
444508
this.listeners.clear();
445509
}
510+
}
511+
512+
export type PromptUpdateKind = 'fileSystem' | 'textModel';
513+
514+
export interface IPromptUpdateEvent {
515+
kind: PromptUpdateKind;
516+
uri?: URI;
517+
}
518+
519+
export class PromptUpdateTracker extends Disposable {
520+
521+
private static readonly PROMPT_UPDATE_DELAY_MS = 200;
522+
523+
private readonly listeners = new ResourceMap<IDisposable>();
524+
private readonly onDidPromptModelChange: Emitter<IPromptUpdateEvent>;
525+
526+
public get onDiDPromptChange(): Event<IPromptUpdateEvent> {
527+
return this.onDidPromptModelChange.event;
528+
}
446529

530+
constructor(
531+
fileLocator: PromptFilesLocator,
532+
@IModelService modelService: IModelService,
533+
) {
534+
super();
535+
this.onDidPromptModelChange = this._register(new Emitter<IPromptUpdateEvent>());
536+
const delayer = this._register(new Delayer<void>(PromptUpdateTracker.PROMPT_UPDATE_DELAY_MS));
537+
const trigger = (event: IPromptUpdateEvent) => delayer.trigger(() => this.onDidPromptModelChange.fire(event));
538+
539+
const filesUpdatedEventRegistration = this._register(fileLocator.createFilesUpdatedEvent(PromptsType.prompt));
540+
this._register(filesUpdatedEventRegistration.event(() => trigger({ kind: 'fileSystem' })));
541+
542+
const onAdd = (model: ITextModel) => {
543+
if (model.getLanguageId() === PROMPT_LANGUAGE_ID) {
544+
this.listeners.set(model.uri, model.onDidChangeContent(() => trigger({ kind: 'textModel', uri: model.uri })));
545+
}
546+
};
547+
const onRemove = (languageId: string, uri: URI) => {
548+
if (languageId === PROMPT_LANGUAGE_ID) {
549+
this.listeners.get(uri)?.dispose();
550+
this.listeners.delete(uri);
551+
trigger({ kind: 'textModel', uri });
552+
}
553+
};
554+
this._register(modelService.onModelAdded(model => onAdd(model)));
555+
this._register(modelService.onModelLanguageChanged(e => {
556+
onRemove(e.oldLanguageId, e.model.uri);
557+
onAdd(e.model);
558+
}));
559+
this._register(modelService.onModelRemoved(model => onRemove(model.getLanguageId(), model.uri)));
560+
}
561+
562+
public override dispose(): void {
563+
super.dispose();
564+
this.listeners.forEach(listener => listener.dispose());
565+
this.listeners.clear();
566+
}
447567
}
448568

449569
namespace IAgentSource {

src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { CancellationToken } from '../../../../../base/common/cancellation.js';
7-
import { Emitter } from '../../../../../base/common/event.js';
7+
import { Emitter, Event } from '../../../../../base/common/event.js';
88
import { IDisposable } from '../../../../../base/common/lifecycle.js';
99
import { URI } from '../../../../../base/common/uri.js';
1010
import { ITextModel } from '../../../../../editor/common/model.js';
@@ -37,6 +37,8 @@ export class MockPromptsService implements IPromptsService {
3737
getSourceFolders(_type: any): readonly any[] { throw new Error('Not implemented'); }
3838
asPromptSlashCommand(_command: string): any { return undefined; }
3939
resolvePromptSlashCommand(_data: any, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
40+
resolvePromptSlashCommandFromCache(command: string): ParsedPromptFile | undefined { throw new Error('Not implemented'); }
41+
get onDidChangeParsedPromptFilesCache(): Event<void> { throw new Error('Not implemented'); }
4042
findPromptSlashCommands(): Promise<any[]> { throw new Error('Not implemented'); }
4143
getPromptCommandName(uri: URI): Promise<string> { throw new Error('Not implemented'); }
4244
parse(_uri: URI, _type: any, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }

src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
import assert from 'assert';
77
import * as sinon from 'sinon';
88
import { CancellationToken } from '../../../../../../../base/common/cancellation.js';
9-
import { Event } from '../../../../../../../base/common/event.js';
109
import { ResourceSet } from '../../../../../../../base/common/map.js';
1110
import { Schemas } from '../../../../../../../base/common/network.js';
1211
import { URI } from '../../../../../../../base/common/uri.js';
1312
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
1413
import { Range } from '../../../../../../../editor/common/core/range.js';
1514
import { ILanguageService } from '../../../../../../../editor/common/languages/language.js';
1615
import { IModelService } from '../../../../../../../editor/common/services/model.js';
16+
import { ModelService } from '../../../../../../../editor/common/services/modelService.js';
1717
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
1818
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
1919
import { IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js';
@@ -70,7 +70,9 @@ suite('PromptsService', () => {
7070

7171
const fileService = disposables.add(instaService.createInstance(FileService));
7272
instaService.stub(IFileService, fileService);
73-
instaService.stub(IModelService, { getModel() { return null; }, onModelRemoved: Event.None });
73+
74+
const modelService = disposables.add(instaService.createInstance(ModelService));
75+
instaService.stub(IModelService, modelService);
7476
instaService.stub(ILanguageService, {
7577
guessLanguageIdByFilepathOrFirstLine(uri: URI) {
7678
if (uri.path.endsWith(PROMPT_FILE_EXTENSION)) {

0 commit comments

Comments
 (0)