Skip to content

Commit fd397c4

Browse files
authored
Merge branch 'main' into digitarald/boiling-badger
2 parents fe76bb3 + 5493635 commit fd397c4

File tree

12 files changed

+479
-191
lines changed

12 files changed

+479
-191
lines changed

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

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,21 +1268,20 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr
12681268
const params = new URLSearchParams(url.query);
12691269
this.telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: CHAT_SETUP_ACTION_ID, from: 'url', detail: params.get('referrer') ?? undefined });
12701270

1271-
const modeParam = params.get('agent') ?? params.get('mode');
1272-
let modeToUse: ChatModeKind | string | undefined;
1273-
if (modeParam) {
1274-
// check if the given param is a valid mode ID
1275-
let foundMode = this.chatModeService.findModeById(modeParam);
1276-
if (!foundMode) {
1277-
// if not, check if the given param is a valid mode name, note the name is case insensitive
1278-
foundMode = this.chatModeService.findModeByName(modeParam);
1279-
}
1271+
const agentParam = params.get('agent') ?? params.get('mode');
1272+
if (agentParam) {
1273+
const agents = this.chatModeService.getModes();
1274+
const allAgents = [...agents.builtin, ...agents.custom];
12801275

1281-
if (foundMode) {
1282-
modeToUse = foundMode.id;
1276+
// check if the given param is a valid mode ID
1277+
let foundAgent = allAgents.find(agent => agent.id === agentParam);
1278+
if (!foundAgent) {
1279+
// if not, check if the given param is a valid mode name, note the parameter as name is case insensitive
1280+
const nameLower = agentParam.toLowerCase();
1281+
foundAgent = allAgents.find(agent => agent.name.toLowerCase() === nameLower);
12831282
}
12841283
// execute the command to change the mode in panel, note that the command only supports mode IDs, not names
1285-
await this.commandService.executeCommand(CHAT_SETUP_ACTION_ID, modeToUse);
1284+
await this.commandService.executeCommand(CHAT_SETUP_ACTION_ID, foundAgent?.id);
12861285
return true;
12871286
}
12881287

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

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import { ChatContextKeys } from '../common/chatContextKeys.js';
6363
import { applyingChatEditsFailedContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, inChatEditingSessionContextKey, ModifiedFileEntryState } from '../common/chatEditingService.js';
6464
import { IChatLayoutService } from '../common/chatLayoutService.js';
6565
import { IChatModel, IChatResponseModel } from '../common/chatModel.js';
66-
import { IChatModeService } from '../common/chatModes.js';
66+
import { ChatMode, IChatModeService } from '../common/chatModes.js';
6767
import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../common/chatParserTypes.js';
6868
import { ChatRequestParser } from '../common/chatRequestParser.js';
6969
import { IChatLocationData, IChatSendRequestOptions, IChatService } from '../common/chatService.js';
@@ -1760,7 +1760,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
17601760

17611761
// Switch to the specified agent/mode if provided
17621762
if (handoff.agent) {
1763-
this.input.setChatMode(handoff.agent);
1763+
this._switchToAgentByName(handoff.agent);
17641764
}
17651765
// Insert the handoff prompt into the input
17661766
this.input.setValue(handoff.prompt, false);
@@ -2961,31 +2961,37 @@ export class ChatWidget extends Disposable implements IChatWidget {
29612961
this.agentInInput.set(!!currentAgent);
29622962
}
29632963

2964-
private async _applyPromptMetadata({ agent: mode, tools, model }: PromptHeader, requestInput: IChatRequestInputOptions): Promise<void> {
2965-
2966-
const currentMode = this.input.currentModeObs.get();
2967-
2968-
if (tools !== undefined && !mode && currentMode.kind !== ChatModeKind.Agent) {
2969-
mode = ChatModeKind.Agent;
2970-
}
2964+
private async _switchToAgentByName(agentName: string): Promise<void> {
2965+
const currentAgent = this.input.currentModeObs.get();
29712966

29722967
// switch to appropriate agent if needed
2973-
if (mode && mode !== currentMode.name) {
2968+
if (agentName !== currentAgent.name) {
29742969
// Find the mode object to get its kind
2975-
const chatMode = this.chatModeService.findModeByName(mode);
2976-
if (chatMode) {
2977-
if (currentMode.kind !== chatMode.kind) {
2978-
const chatModeCheck = await this.instantiationService.invokeFunction(handleModeSwitch, currentMode.kind, chatMode.kind, this.viewModel?.model.getRequests().length ?? 0, this.viewModel?.model.editingSession);
2970+
const agent = this.chatModeService.findModeByName(agentName);
2971+
if (agent) {
2972+
if (currentAgent.kind !== agent.kind) {
2973+
const chatModeCheck = await this.instantiationService.invokeFunction(handleModeSwitch, currentAgent.kind, agent.kind, this.viewModel?.model.getRequests().length ?? 0, this.viewModel?.model.editingSession);
29792974
if (!chatModeCheck) {
29802975
return undefined;
29812976
} else if (chatModeCheck.needToClearSession) {
29822977
this.clear();
29832978
await this.waitForReady();
29842979
}
29852980
}
2986-
this.input.setChatMode(chatMode.id);
2981+
this.input.setChatMode(agent.id);
29872982
}
29882983
}
2984+
}
2985+
2986+
private async _applyPromptMetadata({ agent, tools, model }: PromptHeader, requestInput: IChatRequestInputOptions): Promise<void> {
2987+
2988+
if (tools !== undefined && !agent && this.input.currentModeKind !== ChatModeKind.Agent) {
2989+
agent = ChatMode.Agent.name;
2990+
}
2991+
// switch to appropriate agent if needed
2992+
if (agent) {
2993+
this._switchToAgentByName(agent);
2994+
}
29892995

29902996
// if not tools to enable are present, we are done
29912997
if (tools !== undefined && this.input.currentModeKind === ChatModeKind.Agent) {

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/chatModes.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,7 @@ export class ChatModeService extends Disposable implements IChatModeService {
176176
}
177177

178178
findModeByName(name: string): IChatMode | undefined {
179-
const lowerCasedName = name.toLowerCase();
180-
return this.getBuiltinModes().find(mode => mode.name.toLowerCase() === lowerCasedName) ?? this.getCustomModes().find(mode => mode.name.toLowerCase() === lowerCasedName);
179+
return this.getBuiltinModes().find(mode => mode.name === name) ?? this.getCustomModes().find(mode => mode.name === name);
181180
}
182181

183182
private getBuiltinModes(): IChatMode[] {

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 {

0 commit comments

Comments
 (0)