@@ -19,6 +19,7 @@ import { PromptsType } from '../common/promptSyntax/promptTypes.js';
1919import { CancellationToken } from '../../../../base/common/cancellation.js' ;
2020import { localize } from '../../../../nls.js' ;
2121import { ILogService } from '../../../../platform/log/common/log.js' ;
22+ import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js' ;
2223
2324export const IChatTipService = createDecorator < IChatTipService > ( 'chatTipService' ) ;
2425
@@ -85,6 +86,21 @@ export interface ITipDefinition {
8586 * Matches against both mode kind (e.g. 'agent') and mode name (e.g. 'Plan').
8687 */
8788 readonly excludeWhenModesUsed ?: string [ ] ;
89+ /**
90+ * Tool IDs that, if ever invoked in this workspace, make this tip ineligible.
91+ * The tip won't be shown if the tool it describes has already been used.
92+ */
93+ readonly excludeWhenToolsInvoked ?: string [ ] ;
94+ /**
95+ * If set, exclude this tip when prompt files of the specified type exist in the workspace.
96+ */
97+ readonly excludeWhenPromptFilesExist ?: {
98+ readonly promptType : PromptsType ;
99+ /** Also check for this specific agent instruction file type. */
100+ readonly agentFileType ?: AgentFileType ;
101+ /** If true, exclude the tip until the async file check completes. Default: false. */
102+ readonly excludeUntilChecked ?: boolean ;
103+ } ;
88104}
89105
90106/**
@@ -108,10 +124,12 @@ const TIP_CATALOG: ITipDefinition[] = [
108124 {
109125 id : 'tip.attachFiles' ,
110126 message : localize ( 'tip.attachFiles' , "Tip: Attach files or folders with # to give Copilot more context." ) ,
127+ excludeWhenCommandsExecuted : [ 'workbench.action.chat.attachContext' , 'workbench.action.chat.attachFile' , 'workbench.action.chat.attachFolder' , 'workbench.action.chat.attachSelection' ] ,
111128 } ,
112129 {
113130 id : 'tip.codeActions' ,
114131 message : localize ( 'tip.codeActions' , "Tip: Select code and right-click for Copilot actions in the context menu." ) ,
132+ excludeWhenCommandsExecuted : [ 'inlineChat.start' ] ,
115133 } ,
116134 {
117135 id : 'tip.undoChanges' ,
@@ -126,7 +144,78 @@ const TIP_CATALOG: ITipDefinition[] = [
126144 id : 'tip.customInstructions' ,
127145 message : localize ( 'tip.customInstructions' , "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so Copilot always has the context it needs when starting a task." ) ,
128146 enabledCommands : [ 'workbench.action.chat.generateInstructions' ] ,
129- }
147+ excludeWhenPromptFilesExist : { promptType : PromptsType . instructions , agentFileType : AgentFileType . copilotInstructionsMd , excludeUntilChecked : true } ,
148+ } ,
149+ {
150+ id : 'tip.customAgent' ,
151+ message : localize ( 'tip.customAgent' , "Tip: [Create a custom agent](command:workbench.command.new.agent) to define reusable personas with tailored instructions and tools for your workflow." ) ,
152+ when : ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
153+ enabledCommands : [ 'workbench.command.new.agent' ] ,
154+ excludeWhenCommandsExecuted : [ 'workbench.command.new.agent' ] ,
155+ excludeWhenPromptFilesExist : { promptType : PromptsType . agent , excludeUntilChecked : true } ,
156+ } ,
157+ {
158+ id : 'tip.skill' ,
159+ message : localize ( 'tip.skill' , "Tip: [Create a skill](command:workbench.command.new.skill) so agents can perform domain-specific tasks with reusable prompts and tools." ) ,
160+ when : ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
161+ enabledCommands : [ 'workbench.command.new.skill' ] ,
162+ excludeWhenCommandsExecuted : [ 'workbench.command.new.skill' ] ,
163+ excludeWhenPromptFilesExist : { promptType : PromptsType . skill , excludeUntilChecked : true } ,
164+ } ,
165+ {
166+ id : 'tip.messageQueueing' ,
167+ message : localize ( 'tip.messageQueueing' , "Tip: You can send follow-up messages while the agent is working. They'll be queued and processed in order." ) ,
168+ when : ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
169+ excludeWhenCommandsExecuted : [ 'workbench.action.chat.queueMessage' , 'workbench.action.chat.steerWithMessage' ] ,
170+ } ,
171+ {
172+ id : 'tip.yoloMode' ,
173+ message : localize ( 'tip.yoloMode' , "Tip: Enable [auto-approve mode](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation." ) ,
174+ when : ContextKeyExpr . and (
175+ ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
176+ ContextKeyExpr . notEquals ( 'config.chat.tools.global.autoApprove' , true ) ,
177+ ) ,
178+ enabledCommands : [ 'workbench.action.openSettings' ] ,
179+ } ,
180+ {
181+ id : 'tip.mermaid' ,
182+ message : localize ( 'tip.mermaid' , "Tip: Ask the agent to visualize architectures and flows; it can render Mermaid diagrams directly in chat." ) ,
183+ when : ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
184+ excludeWhenToolsInvoked : [ 'renderMermaidDiagram' ] ,
185+ } ,
186+ {
187+ id : 'tip.githubRepo' ,
188+ message : localize ( 'tip.githubRepo' , "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt so the agent can query code and issues across that repo." ) ,
189+ when : ContextKeyExpr . and (
190+ ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
191+ ContextKeyExpr . notEquals ( 'gitOpenRepositoryCount' , '0' ) ,
192+ ) ,
193+ excludeWhenToolsInvoked : [ 'github-pull-request_doSearch' , 'github-pull-request_issue_fetch' , 'github-pull-request_formSearchQuery' ] ,
194+ } ,
195+ {
196+ id : 'tip.subagents' ,
197+ message : localize ( 'tip.subagents' , "Tip: Ask the agent to implement a plan in parallel; it can delegate work across subagents for faster results." ) ,
198+ when : ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
199+ excludeWhenToolsInvoked : [ 'runSubagent' ] ,
200+ } ,
201+ {
202+ id : 'tip.contextUsage' ,
203+ message : localize ( 'tip.contextUsage' , "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being spent and what's consuming them." ) ,
204+ when : ContextKeyExpr . and (
205+ ChatContextKeys . chatModeKind . isEqualTo ( ChatModeKind . Agent ) ,
206+ ChatContextKeys . contextUsageHasBeenOpened . negate ( ) ,
207+ ChatContextKeys . chatSessionIsEmpty . negate ( ) ,
208+ ) ,
209+ enabledCommands : [ 'workbench.action.chat.showContextUsage' ] ,
210+ excludeWhenCommandsExecuted : [ 'workbench.action.chat.showContextUsage' ] ,
211+ } ,
212+ {
213+ id : 'tip.sendToNewChat' ,
214+ message : localize ( 'tip.sendToNewChat' , "Tip: Use [Send to New Chat](command:workbench.action.chat.sendToNewChat) to start a fresh conversation with a clean context window." ) ,
215+ when : ChatContextKeys . chatSessionIsEmpty . negate ( ) ,
216+ enabledCommands : [ 'workbench.action.chat.sendToNewChat' ] ,
217+ excludeWhenCommandsExecuted : [ 'workbench.action.chat.sendToNewChat' ] ,
218+ } ,
130219] ;
131220
132221/**
@@ -138,26 +227,38 @@ export class TipEligibilityTracker extends Disposable {
138227
139228 private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands' ;
140229 private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes' ;
230+ private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools' ;
141231
142232 private readonly _executedCommands : Set < string > ;
143233 private readonly _usedModes : Set < string > ;
234+ private readonly _invokedTools : Set < string > ;
144235
145236 private readonly _pendingCommands : Set < string > ;
146237 private readonly _pendingModes : Set < string > ;
238+ private readonly _pendingTools : Set < string > ;
147239
148240 private readonly _commandListener = this . _register ( new MutableDisposable ( ) ) ;
241+ private readonly _toolListener = this . _register ( new MutableDisposable ( ) ) ;
149242
150243 /**
151- * Whether agent instruction files exist in the workspace.
152- * Defaults to `true` (hide the tip) until the async check completes .
244+ * Tip IDs excluded because prompt files of the required type exist in the workspace.
245+ * Tips with `excludeUntilChecked` are pre-added and removed if no files are found .
153246 */
154- private _hasInstructionFiles = true ;
247+ private readonly _excludedByFiles = new Set < string > ( ) ;
248+
249+ /** Tips that have file-based exclusions, kept for re-checks. */
250+ private readonly _tipsWithFileExclusions : readonly ITipDefinition [ ] ;
251+
252+ /** Generation counter per tip ID to discard stale async file-check results. */
253+ private readonly _fileCheckGeneration = new Map < string , number > ( ) ;
155254
156255 constructor (
157256 tips : readonly ITipDefinition [ ] ,
158257 @ICommandService commandService : ICommandService ,
159258 @IStorageService private readonly _storageService : IStorageService ,
160- @IPromptsService promptsService : IPromptsService ,
259+ @IPromptsService private readonly _promptsService : IPromptsService ,
260+ @ILanguageModelToolsService languageModelToolsService : ILanguageModelToolsService ,
261+ @ILogService private readonly _logService : ILogService ,
161262 ) {
162263 super ( ) ;
163264
@@ -169,6 +270,9 @@ export class TipEligibilityTracker extends Disposable {
169270 const storedModes = this . _storageService . get ( TipEligibilityTracker . _MODES_STORAGE_KEY , StorageScope . WORKSPACE ) ;
170271 this . _usedModes = new Set < string > ( storedModes ? JSON . parse ( storedModes ) : [ ] ) ;
171272
273+ const storedTools = this . _storageService . get ( TipEligibilityTracker . _TOOLS_STORAGE_KEY , StorageScope . WORKSPACE ) ;
274+ this . _invokedTools = new Set < string > ( storedTools ? JSON . parse ( storedTools ) : [ ] ) ;
275+
172276 // --- Derive what still needs tracking ----------------------------------
173277
174278 this . _pendingCommands = new Set < string > ( ) ;
@@ -189,6 +293,15 @@ export class TipEligibilityTracker extends Disposable {
189293 }
190294 }
191295
296+ this . _pendingTools = new Set < string > ( ) ;
297+ for ( const tip of tips ) {
298+ for ( const toolId of tip . excludeWhenToolsInvoked ?? [ ] ) {
299+ if ( ! this . _invokedTools . has ( toolId ) ) {
300+ this . _pendingTools . add ( toolId ) ;
301+ }
302+ }
303+ }
304+
192305 // --- Set up command listener (auto-disposes when all seen) --------------
193306
194307 if ( this . _pendingCommands . size > 0 ) {
@@ -205,9 +318,40 @@ export class TipEligibilityTracker extends Disposable {
205318 } ) ;
206319 }
207320
208- // --- Async file check --------------------------------------------------
321+ // --- Set up tool listener (auto-disposes when all seen) -----------------
322+
323+ if ( this . _pendingTools . size > 0 ) {
324+ this . _toolListener . value = languageModelToolsService . onDidInvokeTool ( e => {
325+ if ( this . _pendingTools . has ( e . toolId ) ) {
326+ this . _invokedTools . add ( e . toolId ) ;
327+ this . _persistSet ( TipEligibilityTracker . _TOOLS_STORAGE_KEY , this . _invokedTools ) ;
328+ this . _pendingTools . delete ( e . toolId ) ;
329+
330+ if ( this . _pendingTools . size === 0 ) {
331+ this . _toolListener . clear ( ) ;
332+ }
333+ }
334+ } ) ;
335+ }
336+
337+ // --- Async file checks -------------------------------------------------
338+
339+ this . _tipsWithFileExclusions = tips . filter ( t => t . excludeWhenPromptFilesExist ) ;
340+ for ( const tip of this . _tipsWithFileExclusions ) {
341+ if ( tip . excludeWhenPromptFilesExist ! . excludeUntilChecked ) {
342+ this . _excludedByFiles . add ( tip . id ) ;
343+ }
344+ this . _checkForPromptFiles ( tip ) ;
345+ }
209346
210- this . _checkForInstructionFiles ( promptsService ) ;
347+ // Re-check agent file exclusions when custom agents change (covers late discovery)
348+ this . _register ( this . _promptsService . onDidChangeCustomAgents ( ( ) => {
349+ for ( const tip of this . _tipsWithFileExclusions ) {
350+ if ( tip . excludeWhenPromptFilesExist ! . promptType === PromptsType . agent ) {
351+ this . _checkForPromptFiles ( tip ) ;
352+ }
353+ }
354+ } ) ) ;
211355 }
212356
213357 /**
@@ -245,33 +389,67 @@ export class TipEligibilityTracker extends Disposable {
245389 if ( tip . excludeWhenCommandsExecuted ) {
246390 for ( const cmd of tip . excludeWhenCommandsExecuted ) {
247391 if ( this . _executedCommands . has ( cmd ) ) {
392+ this . _logService . debug ( '#ChatTips: tip excluded because command was executed' , tip . id , cmd ) ;
248393 return true ;
249394 }
250395 }
251396 }
252397 if ( tip . excludeWhenModesUsed ) {
253398 for ( const mode of tip . excludeWhenModesUsed ) {
254399 if ( this . _usedModes . has ( mode ) ) {
400+ this . _logService . debug ( '#ChatTips: tip excluded because mode was used' , tip . id , mode ) ;
401+ return true ;
402+ }
403+ }
404+ }
405+ if ( tip . excludeWhenToolsInvoked ) {
406+ for ( const toolId of tip . excludeWhenToolsInvoked ) {
407+ if ( this . _invokedTools . has ( toolId ) ) {
408+ this . _logService . debug ( '#ChatTips: tip excluded because tool was invoked' , tip . id , toolId ) ;
255409 return true ;
256410 }
257411 }
258412 }
259- if ( tip . id === 'tip.customInstructions' && this . _hasInstructionFiles ) {
413+ if ( tip . excludeWhenPromptFilesExist && this . _excludedByFiles . has ( tip . id ) ) {
414+ this . _logService . debug ( '#ChatTips: tip excluded because prompt files exist' , tip . id ) ;
260415 return true ;
261416 }
262417 return false ;
263418 }
264419
265- private async _checkForInstructionFiles ( promptsService : IPromptsService ) : Promise < void > {
420+ private async _checkForPromptFiles ( tip : ITipDefinition ) : Promise < void > {
421+ const config = tip . excludeWhenPromptFilesExist ! ;
422+ const generation = ( this . _fileCheckGeneration . get ( tip . id ) ?? 0 ) + 1 ;
423+ this . _fileCheckGeneration . set ( tip . id , generation ) ;
424+
266425 try {
267- const [ agentInstructions , instructionFiles ] = await Promise . all ( [
268- promptsService . listAgentInstructions ( CancellationToken . None ) ,
269- promptsService . listPromptFiles ( PromptsType . instructions , CancellationToken . None ) ,
426+ const [ promptFiles , agentInstructions ] = await Promise . all ( [
427+ this . _promptsService . listPromptFiles ( config . promptType , CancellationToken . None ) ,
428+ config . agentFileType ? this . _promptsService . listAgentInstructions ( CancellationToken . None ) : Promise . resolve ( [ ] ) ,
270429 ] ) ;
271- const hasCopilotInstructions = agentInstructions . some ( f => f . type === AgentFileType . copilotInstructionsMd ) ;
272- this . _hasInstructionFiles = hasCopilotInstructions || instructionFiles . length > 0 ;
430+
431+ // Discard stale result if a newer check was started while we were awaiting
432+ if ( this . _fileCheckGeneration . get ( tip . id ) !== generation ) {
433+ return ;
434+ }
435+
436+ const hasPromptFiles = promptFiles . length > 0 ;
437+ const hasAgentFile = config . agentFileType
438+ ? agentInstructions . some ( f => f . type === config . agentFileType )
439+ : false ;
440+
441+ if ( hasPromptFiles || hasAgentFile ) {
442+ this . _excludedByFiles . add ( tip . id ) ;
443+ } else {
444+ this . _excludedByFiles . delete ( tip . id ) ;
445+ }
273446 } catch {
274- this . _hasInstructionFiles = true ;
447+ if ( this . _fileCheckGeneration . get ( tip . id ) !== generation ) {
448+ return ;
449+ }
450+ if ( config . excludeUntilChecked ) {
451+ this . _excludedByFiles . add ( tip . id ) ;
452+ }
275453 }
276454 }
277455
@@ -413,7 +591,11 @@ export class ChatTipService extends Disposable implements IChatTipService {
413591 this . _logService . debug ( '#ChatTips: tip is not eligible due to when clause' , tip . id , tip . when . serialize ( ) ) ;
414592 return false ;
415593 }
416- return ! this . _tracker . isExcluded ( tip ) ;
594+ if ( this . _tracker . isExcluded ( tip ) ) {
595+ return false ;
596+ }
597+ this . _logService . debug ( '#ChatTips: tip is eligible' , tip . id ) ;
598+ return true ;
417599 }
418600
419601 private _isCopilotEnabled ( ) : boolean {
0 commit comments