From a15532868b411e52311b8cbbb71996598b03d982 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:48:42 -0800 Subject: [PATCH 01/64] Tidy up comments in ShellIntegrationAddon --- .../common/xterm/shellIntegrationAddon.ts | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index f326709753a..5c4bc655e44 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -21,20 +21,17 @@ import { removeAnsiEscapeCodesFromPrompt } from '../../../../base/common/strings import { ShellEnvDetectionCapability } from '../capabilities/shellEnvDetectionCapability.js'; import { PromptTypeDetectionCapability } from '../capabilities/promptTypeDetectionCapability.js'; - -/** - * Shell integration is a feature that enhances the terminal's understanding of what's happening - * in the shell by injecting special sequences into the shell's prompt using the "Set Text - * Parameters" sequence (`OSC Ps ; Pt ST`). - * - * Definitions: - * - OSC: `\x1b]` - * - Ps: A single (usually optional) numeric parameter, composed of one or more digits. - * - Pt: A text parameter composed of printable characters. - * - ST: `\x7` - * - * This is inspired by a feature of the same name in the FinalTerm, iTerm2 and kitty terminals. - */ +// Shell integration is a feature that enhances the terminal's understanding of what's happening +// in the shell by injecting special sequences into the shell's prompt using the "Set Text +// Parameters" sequence (`OSC Ps ; Pt ST`). +// +// Definitions: +// - OSC: `\x1b]` +// - Ps: A single (usually optional) numeric parameter, composed of one or more digits. +// - Pt: A text parameter composed of printable characters. +// - ST: `\x7` +// +// This is inspired by a feature of the same name in the FinalTerm, iTerm2 and kitty terminals. /** * The identifier for the first numeric parameter (`Ps`) for OSC commands used by shell integration. @@ -174,6 +171,8 @@ const enum VSCodeOscPt { /** * Similar to prompt start but for line continuations. * + * Format: `OSC 633 ; F ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ ContinuationStart = 'F', @@ -181,6 +180,8 @@ const enum VSCodeOscPt { /** * Similar to command start but for line continuations. * + * Format: `OSC 633 ; G ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ ContinuationEnd = 'G', @@ -188,6 +189,8 @@ const enum VSCodeOscPt { /** * The start of the right prompt. * + * Format: `OSC 633 ; H ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ RightPromptStart = 'H', @@ -195,6 +198,8 @@ const enum VSCodeOscPt { /** * The end of the right prompt. * + * Format: `OSC 633 ; I ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ RightPromptEnd = 'I', @@ -224,7 +229,7 @@ const enum VSCodeOscPt { /** * Sets a mark/point-of-interest in the buffer. * - * Format: `OSC 633 ; SetMark [; Id=] [; Hidden]` + * Format: `OSC 633 ; SetMark [; Id=] [; Hidden] ST` * * `Id` - The identifier of the mark that can be used to reference it * `Hidden` - When set, the mark will be available to reference internally but will not visible @@ -236,7 +241,7 @@ const enum VSCodeOscPt { /** * Sends the shell's complete environment in JSON format. * - * Format: `OSC 633 ; EnvJson ; ; ` + * Format: `OSC 633 ; EnvJson ; ; ST` * * - `Environment` - A stringified JSON object containing the shell's complete environment. The * variables and values use the same encoding rules as the {@link CommandLine} sequence. @@ -250,7 +255,7 @@ const enum VSCodeOscPt { /** * Delete a single environment variable from cached environment. * - * Format: `OSC 633 ; EnvSingleDelete ; ; [; ]` + * Format: `OSC 633 ; EnvSingleDelete ; ; [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -262,7 +267,7 @@ const enum VSCodeOscPt { /** * The start of the collecting user's environment variables individually. * - * Format: `OSC 633 ; EnvSingleStart ; [; ]` + * Format: `OSC 633 ; EnvSingleStart ; [; ] ST` * * - `Clear` - An _mandatory_ flag indicating any cached environment variables will be cleared. * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order @@ -275,7 +280,7 @@ const enum VSCodeOscPt { /** * Sets an entry of single environment variable to transactional pending map of environment variables. * - * Format: `OSC 633 ; EnvSingleEntry ; ; [; ]` + * Format: `OSC 633 ; EnvSingleEntry ; ; [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -288,7 +293,7 @@ const enum VSCodeOscPt { * The end of the collecting user's environment variables individually. * Clears any pending environment variables and fires an event that contains user's environment. * - * Format: `OSC 633 ; EnvSingleEnd [; ]` + * Format: `OSC 633 ; EnvSingleEnd [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -305,7 +310,7 @@ const enum ITermOscPt { /** * Sets a mark/point-of-interest in the buffer. * - * Format: `OSC 1337 ; SetMark` + * Format: `OSC 1337 ; SetMark ST` */ SetMark = 'SetMark', From efe6215b271e1e7560902e6fede42654f501afdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:56:11 +0000 Subject: [PATCH 02/64] Initial plan From 2ae3e21eebb675c8d86cfd0fa3c696c46891cf7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:21:56 +0000 Subject: [PATCH 03/64] Add offWhenInlineCompletions value for editor.quickSuggestions setting Adds a new 'offWhenInlineCompletions' value to the quickSuggestions setting that disables quick suggestions when an inline completion provider is registered for the current model. When no inline completion provider exists, suggestions behave as if set to 'on'. Also adds experiment: { mode: 'auto' } to the quickSuggestions setting. Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- src/vs/editor/common/config/editorOptions.ts | 13 +++-- .../contrib/suggest/browser/suggestModel.ts | 5 +- .../suggest/test/browser/suggestModel.test.ts | 53 ++++++++++++++++++- src/vs/monaco.d.ts | 2 +- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index a6e7322d41c..e78b3699d0d 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -3712,7 +3712,7 @@ class PlaceholderOption extends BaseEditorOptioninput); - const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off']; + const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off', 'offWhenInlineCompletions']; let validatedOther: QuickSuggestionsValue; let validatedComments: QuickSuggestionsValue; let validatedStrings: QuickSuggestionsValue; diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index fcf97af35a6..e6512a1b8c4 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -415,7 +415,10 @@ export class SuggestModel implements IDisposable { const lineTokens = model.tokenization.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { - return; + if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' + || this._languageFeaturesService.inlineCompletionsProvider.has(model)) { + return; + } } } diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index dccb55ef441..e241ebe443d 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -15,7 +15,7 @@ import { Selection } from '../../../../common/core/selection.js'; import { Handler } from '../../../../common/editorCommon.js'; import { ITextModel } from '../../../../common/model.js'; import { TextModel } from '../../../../common/model/textModel.js'; -import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, IState, TokenizationRegistry } from '../../../../common/languages.js'; +import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, InlineCompletionsProvider, IState, TokenizationRegistry } from '../../../../common/languages.js'; import { MetadataConsts } from '../../../../common/encodedTokenAttributes.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { NullState } from '../../../../common/languages/nullTokenize.js'; @@ -1228,4 +1228,55 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); + + test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); + + return new Promise((resolve, reject) => { + const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => { + unexpectedSuggestSub.dispose(); + reject(new Error('Quick suggestions should not have been triggered')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + // Wait for the quick suggest delay to pass without triggering + setTimeout(() => { + unexpectedSuggestSub.dispose(); + resolve(); + }, 200); + }); + }); + }); + + test('offWhenInlineCompletions - allows quick suggest when no inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // No inline completions provider registered for 'test' scheme + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); + + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); + }); + }); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 22641b4e01a..98888e41c6f 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4587,7 +4587,7 @@ declare namespace monaco.editor { cycle?: boolean; } - export type QuickSuggestionsValue = 'on' | 'inline' | 'off'; + export type QuickSuggestionsValue = 'on' | 'inline' | 'off' | 'offWhenInlineCompletions'; /** * Configuration options for quick suggestions From 1177b2af49d70a84e0855460815da0c75639ef82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:43:59 +0000 Subject: [PATCH 04/64] Also check inlineSuggest.enabled for offWhenInlineCompletions When editor.inlineSuggest.enabled is false, offWhenInlineCompletions should allow quick suggestions even if inline completion providers are registered, since they won't produce results anyway. Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../contrib/suggest/browser/suggestModel.ts | 2 +- .../suggest/test/browser/suggestModel.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index e6512a1b8c4..30c1276d5c4 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -416,7 +416,7 @@ export class SuggestModel implements IDisposable { const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' - || this._languageFeaturesService.inlineCompletionsProvider.has(model)) { + || (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) { return; } } diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index e241ebe443d..a9cad96ecae 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -1279,4 +1279,31 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); }); + + test('offWhenInlineCompletions - allows quick suggest when inlineSuggest is disabled', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + inlineSuggest: { enabled: false } + }); + + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); + }); + }); }); From 53331dfc51b694f06f06faedc3c8c5e7f7990eb1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:04:16 +0000 Subject: [PATCH 05/64] Support string shorthand for editor.quickSuggestions setting A plain string value like 'off' or 'offWhenInlineCompletions' is now accepted as shorthand for applying the same value to all token types (comments, strings, other). The JSON schema, validate method, and TypeScript types are updated accordingly. Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- src/vs/editor/common/config/editorOptions.ts | 58 ++++++++++++------- .../suggest/test/browser/suggestModel.test.ts | 56 ++++++++++++++++++ src/vs/monaco.d.ts | 2 +- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index e78b3699d0d..acc23078dae 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -496,7 +496,7 @@ export interface IEditorOptions { * Enable quick suggestions (shadow suggestions) * Defaults to true. */ - quickSuggestions?: boolean | IQuickSuggestionsOptions; + quickSuggestions?: boolean | QuickSuggestionsValue | IQuickSuggestionsOptions; /** * Quick suggestions show delay (in ms) * Defaults to 10 (ms) @@ -3729,7 +3729,7 @@ export interface InternalQuickSuggestionsOptions { readonly strings: QuickSuggestionsValue; } -class EditorQuickSuggestions extends BaseEditorOption { +class EditorQuickSuggestions extends BaseEditorOption { public override readonly defaultValue: InternalQuickSuggestionsOptions; @@ -3748,25 +3748,35 @@ class EditorQuickSuggestions extends BaseEditorOption apply same value to all token types + const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off', 'offWhenInlineCompletions']; + const validated = stringSet(input as QuickSuggestionsValue, this.defaultValue.other, allowedValues); + return { comments: validated, strings: validated, other: validated }; + } if (!input || typeof input !== 'object') { - // invalid object + // invalid input return this.defaultValue; } diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index a9cad96ecae..9b41b76c205 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -1306,4 +1306,60 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); }); + + test('string shorthand - "off" disables quick suggestions for all token types', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + return withOracle((suggestOracle, editor) => { + // Use string shorthand instead of object form + editor.updateOptions({ quickSuggestions: 'off' as any }); + + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by string shorthand "off"')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); + }); + }); + }); + + test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + // Use string shorthand — applies to all token types + editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' as any }); + + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); + }); + }); + }); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 98888e41c6f..cc990e5b8a2 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3645,7 +3645,7 @@ declare namespace monaco.editor { * Enable quick suggestions (shadow suggestions) * Defaults to true. */ - quickSuggestions?: boolean | IQuickSuggestionsOptions; + quickSuggestions?: boolean | QuickSuggestionsValue | IQuickSuggestionsOptions; /** * Quick suggestions show delay (in ms) * Defaults to 10 (ms) From a062197ba8cd8fa51ce2640d3288dec8c9fd3892 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 9 Feb 2026 16:23:38 +0100 Subject: [PATCH 06/64] Refactor quickSuggestions option to use string shorthand in tests and reorder snippetSuggestions options --- .../editor/contrib/suggest/test/browser/suggestModel.test.ts | 4 ++-- src/vs/monaco.d.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index 9b41b76c205..4701782c7d4 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -1313,7 +1313,7 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { return withOracle((suggestOracle, editor) => { // Use string shorthand instead of object form - editor.updateOptions({ quickSuggestions: 'off' as any }); + editor.updateOptions({ quickSuggestions: 'off' }); return new Promise((resolve, reject) => { const sub = suggestOracle.onDidSuggest(() => { @@ -1344,7 +1344,7 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { return withOracle((suggestOracle, editor) => { // Use string shorthand — applies to all token types - editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' as any }); + editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); return new Promise((resolve, reject) => { const sub = suggestOracle.onDidSuggest(() => { diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index cc990e5b8a2..be805ae83df 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -5374,7 +5374,7 @@ declare namespace monaco.editor { showUnused: IEditorOption; showDeprecated: IEditorOption; inlayHints: IEditorOption>>; - snippetSuggestions: IEditorOption; + snippetSuggestions: IEditorOption; smartSelect: IEditorOption>>; smoothScrolling: IEditorOption; stopRenderingLineAfter: IEditorOption; From 45a475526f97df6e3ab5df1c994263395f558379 Mon Sep 17 00:00:00 2001 From: Johannes Date: Mon, 9 Feb 2026 17:20:56 +0100 Subject: [PATCH 07/64] Add tests for quickSuggestions using string shorthand and faked timers --- .../suggest/test/browser/suggestModel.test.ts | 117 +++++++++--------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index 4701782c7d4..ff465706c0c 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -41,6 +41,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browser/suggest.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1281,84 +1282,88 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); test('offWhenInlineCompletions - allows quick suggest when inlineSuggest is disabled', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); - disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); - // Register a dummy inline completions provider - const inlineProvider: InlineCompletionsProvider = { - provideInlineCompletions: () => ({ items: [] }), - disposeInlineCompletions: () => { } - }; - disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + inlineSuggest: { enabled: false } + }); - return withOracle((suggestOracle, editor) => { - editor.updateOptions({ - quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, - inlineSuggest: { enabled: false } - }); - - return assertEvent(suggestOracle.onDidSuggest, () => { - editor.setPosition({ lineNumber: 1, column: 4 }); - editor.trigger('keyboard', Handler.Type, { text: 'd' }); - }, suggestEvent => { - assert.strictEqual(suggestEvent.triggerOptions.auto, true); - assert.strictEqual(suggestEvent.completionModel.items.length, 1); + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); }); }); }); test('string shorthand - "off" disables quick suggestions for all token types', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { - disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); - return withOracle((suggestOracle, editor) => { - // Use string shorthand instead of object form - editor.updateOptions({ quickSuggestions: 'off' }); + return withOracle((suggestOracle, editor) => { + // Use string shorthand instead of object form + editor.updateOptions({ quickSuggestions: 'off' }); - return new Promise((resolve, reject) => { - const sub = suggestOracle.onDidSuggest(() => { - sub.dispose(); - reject(new Error('Quick suggestions should have been suppressed by string shorthand "off"')); + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by string shorthand "off"')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); }); - - editor.setPosition({ lineNumber: 1, column: 4 }); - editor.trigger('keyboard', Handler.Type, { text: 'd' }); - - setTimeout(() => { - sub.dispose(); - resolve(); - }, 200); }); }); }); test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); - disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); - const inlineProvider: InlineCompletionsProvider = { - provideInlineCompletions: () => ({ items: [] }), - disposeInlineCompletions: () => { } - }; - disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + return withOracle((suggestOracle, editor) => { + // Use string shorthand — applies to all token types + editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); - return withOracle((suggestOracle, editor) => { - // Use string shorthand — applies to all token types - editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); + }); - return new Promise((resolve, reject) => { - const sub = suggestOracle.onDidSuggest(() => { - sub.dispose(); - reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); }); - - editor.setPosition({ lineNumber: 1, column: 4 }); - editor.trigger('keyboard', Handler.Type, { text: 'd' }); - - setTimeout(() => { - sub.dispose(); - resolve(); - }, 200); }); }); }); From 474b6d0eec1ecca29b9fb05094ea6bb5a147e66a Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 11:25:37 -0800 Subject: [PATCH 08/64] Add guidelines and prompt for fixing unhandled errors from VS Code error telemetry --- .github/prompts/fix-error.prompt.md | 17 +++++++ .github/skills/fix-errors/SKILL.md | 71 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 .github/prompts/fix-error.prompt.md create mode 100644 .github/skills/fix-errors/SKILL.md diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md new file mode 100644 index 00000000000..1cdc8fa90b4 --- /dev/null +++ b/.github/prompts/fix-error.prompt.md @@ -0,0 +1,17 @@ +--- +agent: agent +description: 'Fix an unhandled error from the VS Code error telemetry dashboard' +argument-hint: Paste the GitHub issue URL for the error-telemetry issue +tools: ['edit', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'execute/createAndRunTask', 'execute/runTask', 'read/getTaskOutput', 'search/usages', 'read/problems', 'search/changes', 'execute/testFailure', 'todo', 'execute/runTests', 'web/fetch', 'web/githubRepo'] +--- + +The user has given you a GitHub issue URL for an unhandled error from the VS Code error telemetry dashboard. Fetch the issue to retrieve its details (error message, stack trace, hit count, affected users). + +Follow the `fix-errors` skill guidelines to fix this error. Key principles: + +1. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. +2. **Trace the data flow upward** through the call stack to find the producer of invalid data. +3. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. +4. **If the producer is identifiable**, fix it directly. + +After making changes, check for compilation errors via the build task and run relevant unit tests. diff --git a/.github/skills/fix-errors/SKILL.md b/.github/skills/fix-errors/SKILL.md new file mode 100644 index 00000000000..7a03d11ee0c --- /dev/null +++ b/.github/skills/fix-errors/SKILL.md @@ -0,0 +1,71 @@ +--- +name: fix-errors +description: Guidelines for fixing unhandled errors from the VS Code error telemetry dashboard. Use when investigating error-telemetry issues with stack traces, error messages, and hit/user counts. Covers tracing data flow through call stacks, identifying producers of invalid data vs. consumers that crash, enriching error messages for telemetry diagnosis, and avoiding common anti-patterns like silently swallowing errors. +--- + +When fixing an unhandled error from the telemetry dashboard, the issue typically contains an error message, a stack trace, hit count, and affected user count. + +## Approach + +### 1. Do NOT fix at the crash site + +The error manifests at a specific line in the stack trace, but **the fix almost never belongs there**. Fixing at the crash site (e.g., adding a `typeof` guard in a `revive()` function, swallowing the error with a try/catch, or returning a fallback value) only masks the real problem. The invalid data still flows through the system and will cause failures elsewhere. + +### 2. Trace the data flow upward through the call stack + +Read each frame in the stack trace from bottom to top. For each frame, understand: +- What data is being passed and what is expected +- Where that data originated (IPC message, extension API call, storage, user input, etc.) +- Whether the data could have been corrupted or malformed at that point + +The goal is to find the **producer of invalid data**, not the consumer that crashes on it. + +### 3. When the producer cannot be identified from the stack alone + +Sometimes the stack trace only shows the receiving/consuming side (e.g., an IPC server handler). The sending side is in a different process and not in the stack. In this case: + +- **Enrich the error message** at the consuming site with diagnostic context: the type of the invalid data, a truncated representation of its value, and which operation/command received it. This information flows into the error telemetry dashboard automatically via the unhandled error pipeline. +- **Do NOT silently swallow the error** — let it still throw so it remains visible in telemetry, but with enough context to identify the sender in the next telemetry cycle. +- Consider adding the same enrichment to the low-level validation function that throws (e.g., include the invalid value in the error message) so the telemetry captures it regardless of call site. + +### 4. When the producer IS identifiable + +Fix the producer directly: +- Validate or sanitize data before sending it over IPC / storing it / passing it to APIs +- Ensure serialization/deserialization preserves types correctly (e.g., URI objects should serialize as `UriComponents` objects, not as strings) + +## Example + +Given a stack trace like: +``` +at _validateUri (uri.ts) ← validation throws +at new Uri (uri.ts) ← constructor +at URI.revive (uri.ts) ← revive assumes valid UriComponents +at SomeChannel.call (ipc.ts) ← IPC handler receives arg from another process +``` + +**Wrong fix**: Add a `typeof` guard in `URI.revive` to return `undefined` for non-object input. This silences the error but the caller still expects a valid URI and will fail later. + +**Right fix (when producer is unknown)**: Enrich the error at the IPC handler level and in `_validateUri` itself to include the actual invalid value, so telemetry reveals what data is being sent and from where. Example: +```typescript +// In the IPC handler — validate before revive +function reviveUri(data: UriComponents | URI | undefined | null, context: string): URI { + if (data && typeof data !== 'object') { + throw new Error(`[Channel] Invalid URI data for '${context}': type=${typeof data}, value=${String(data).substring(0, 100)}`); + } + // ... +} + +// In _validateUri — include the scheme value +throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); +``` + +**Right fix (when producer is known)**: Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a `UriComponents` object to a logger creation call, fix that call site to pass the proper object. + +## Guidelines + +- Prefer enriching error messages over adding try/catch guards +- Truncate any user-controlled values included in error messages (to avoid PII and keep messages bounded) +- Do not change the behavior of shared utility functions (like `URI.revive`) in ways that affect all callers — fix at the specific call site or producer +- Run the relevant unit tests after making changes +- Check for compilation errors via the build task before declaring work complete From 6d3266f1d0c53ca166a8cf0674491d89957a3872 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 12:05:19 -0800 Subject: [PATCH 09/64] Fix URI scheme error in LoggerChannelClient.deregisterLogger (#293503) LoggerChannelClient.deregisterLogger accepted only URI but the base interface accepts URI | string. Callers passing string logger IDs (e.g. mcpServer) sent raw strings over IPC, causing URI.revive to treat the string as a scheme and fail validation. Fix: accept URI | string and convert via toResource() before IPC, matching the pattern already used by setVisibility. Also enrich the _validateUri error message with the actual scheme value for future telemetry diagnosis. --- src/vs/base/common/uri.ts | 2 +- src/vs/platform/log/common/logIpc.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index ac2ae962d72..a1e4914f7b7 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,7 +22,7 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error('[UriError]: Scheme contains illegal characters.'); + throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 diff --git a/src/vs/platform/log/common/logIpc.ts b/src/vs/platform/log/common/logIpc.ts index 4a767070d98..584e349ff66 100644 --- a/src/vs/platform/log/common/logIpc.ts +++ b/src/vs/platform/log/common/logIpc.ts @@ -45,9 +45,9 @@ export class LoggerChannelClient extends AbstractLoggerService implements ILogge this.channel.call('registerLogger', [logger, this.windowId]); } - override deregisterLogger(resource: URI): void { - super.deregisterLogger(resource); - this.channel.call('deregisterLogger', [resource, this.windowId]); + override deregisterLogger(idOrResource: URI | string): void { + super.deregisterLogger(idOrResource); + this.channel.call('deregisterLogger', [this.toResource(idOrResource), this.windowId]); } override setLogLevel(logLevel: LogLevel): void; From 3dac14a31ac5dc3c8f2ccf7f5d85a27f5932f0e6 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 12:31:32 -0800 Subject: [PATCH 10/64] Improve URI scheme error message to specify illegal characters found --- src/vs/base/common/uri.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index a1e4914f7b7..ba1dd71eb5e 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,7 +22,8 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); + const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0] ?? (ret.scheme.length > 0 && !/^\w/.test(ret.scheme) ? ret.scheme[0] : '?'); + throw new Error(`[UriError]: Scheme contains illegal characters. Found '${illegalChar}'`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 From 5c54822b7c534e14ed09f45f29b2eefc3f224565 Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:33:11 -0800 Subject: [PATCH 11/64] Update src/vs/platform/log/common/logIpc.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/platform/log/common/logIpc.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/log/common/logIpc.ts b/src/vs/platform/log/common/logIpc.ts index 584e349ff66..a75622ae2b6 100644 --- a/src/vs/platform/log/common/logIpc.ts +++ b/src/vs/platform/log/common/logIpc.ts @@ -46,8 +46,9 @@ export class LoggerChannelClient extends AbstractLoggerService implements ILogge } override deregisterLogger(idOrResource: URI | string): void { - super.deregisterLogger(idOrResource); - this.channel.call('deregisterLogger', [this.toResource(idOrResource), this.windowId]); + const resource = this.toResource(idOrResource); + super.deregisterLogger(resource); + this.channel.call('deregisterLogger', [resource, this.windowId]); } override setLogLevel(logLevel: LogLevel): void; From ec6a947451c7d3541a2aa09ceaa1712d45cb5f87 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 13:14:06 -0800 Subject: [PATCH 12/64] Enhance URI scheme error message to include length and clarify illegal characters --- src/vs/base/common/uri.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index ba1dd71eb5e..19f876a0300 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,8 +22,8 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0] ?? (ret.scheme.length > 0 && !/^\w/.test(ret.scheme) ? ret.scheme[0] : '?'); - throw new Error(`[UriError]: Scheme contains illegal characters. Found '${illegalChar}'`); + const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0]; + throw new Error(`[UriError]: Scheme contains illegal characters.${illegalChar ? ` Found '${illegalChar}'` : ''} (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 From a9535a9f9610e2bac70148477204b78989bebf44 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 13:35:08 -0800 Subject: [PATCH 13/64] Enhance URI scheme validation error message to include index of illegal characters and total count --- src/vs/base/common/uri.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index 19f876a0300..53267ec7b00 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,8 +22,11 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0]; - throw new Error(`[UriError]: Scheme contains illegal characters.${illegalChar ? ` Found '${illegalChar}'` : ''} (len:${ret.scheme.length})`); + const matches = [...ret.scheme.matchAll(/[^\w\d+.-]/gu)]; + const detail = matches.length > 0 + ? ` Found '${matches[0][0]}' at index ${matches[0].index} (${matches.length} total)` + : ''; + throw new Error(`[UriError]: Scheme contains illegal characters.${detail} (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 From 61c7cf9779a54f859f742977bd2080eef1c4cbc1 Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 9 Feb 2026 16:29:07 -0800 Subject: [PATCH 14/64] Add support for continue, stopReason and systemMessage for tool hooks (#294016) --- .../contrib/chat/browser/chat.contribution.ts | 32 ++++ .../tools/languageModelToolsService.ts | 24 ++- .../common/hooks/hooksExecutionService.ts | 145 +++++++++++++++--- .../contrib/chat/common/hooks/hooksTypes.ts | 3 +- .../tools/languageModelToolsService.test.ts | 1 + .../test/common/hooksExecutionService.test.ts | 59 ++++++- 6 files changed, 229 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 2a20ea90483..1c71ce192a5 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1182,6 +1182,37 @@ class ChatResolverContribution extends Disposable { } } +class ChatHooksProgressContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatHooksProgress'; + + constructor( + @IChatService private readonly chatService: IChatService, + @IHooksExecutionService hooksExecutionService: IHooksExecutionService, + ) { + super(); + + this._register(hooksExecutionService.onDidHookProgress(event => { + const model = this.chatService.getSession(event.sessionResource); + if (!model) { + return; + } + + const request = model.getRequests().at(-1); + if (!request) { + return; + } + + this.chatService.appendProgress(request, { + kind: 'hook', + hookType: event.hookType, + stopReason: event.stopReason, + systemMessage: event.systemMessage, + }); + })); + } +} + class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatAgentSetting'; @@ -1448,6 +1479,7 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatHooksProgressContribution.ID, ChatHooksProgressContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 95fa26ab65d..aba6e221b02 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -36,7 +36,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { IPostToolUseCallerInput, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js'; -import { IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js'; +import { HookAbortError, IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; @@ -391,7 +391,16 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolInput: dto.parameters, toolCallId: dto.callId, }; - const hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); + let hookResult: IPreToolUseHookResult | undefined; + try { + hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); + } catch (e) { + if (e instanceof HookAbortError) { + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} aborted by preToolUse hook: ${e.stopReason}`); + throw new CancellationError(); + } + throw e; + } if (hookResult?.permissionDecision === 'deny') { const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); @@ -481,7 +490,16 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo getToolResponseText: () => toolContentToA11yString(toolResult.content), toolCallId: dto.callId, }; - const hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); + let hookResult; + try { + hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); + } catch (e) { + if (e instanceof HookAbortError) { + this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook aborted for tool ${dto.toolId}: ${e.stopReason}`); + throw new CancellationError(); + } + throw e; + } if (hookResult?.decision === 'block') { const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result"); diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts index a8b9d012158..266ce042a49 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts @@ -49,6 +49,16 @@ export interface IHookExecutedEvent { readonly durationMs: number; } +/** + * Event fired when a hook produces progress that should be shown to the user. + */ +export interface IHookProgressEvent { + readonly hookType: HookTypeValue; + readonly sessionResource: URI; + readonly stopReason?: string; + readonly systemMessage?: string; +} + /** * Callback interface for hook execution proxies. * MainThreadHooks implements this to forward calls to the extension host. @@ -67,6 +77,11 @@ export interface IHooksExecutionService { */ readonly onDidExecuteHook: Event; + /** + * Fires when a hook produces progress (warning or stop) that should be shown to the user. + */ + readonly onDidHookProgress: Event; + /** * Called by mainThreadHooks when extension host is ready */ @@ -114,6 +129,9 @@ export class HooksExecutionService extends Disposable implements IHooksExecution private readonly _onDidExecuteHook = this._register(new Emitter()); readonly onDidExecuteHook: Event = this._onDidExecuteHook.event; + private readonly _onDidHookProgress = this._register(new Emitter()); + readonly onDidHookProgress: Event = this._onDidHookProgress.event; + private _proxy: IHooksExecutionProxy | undefined; private readonly _sessionHooks = new Map(); /** Stored transcript path per session (keyed by session URI string). */ @@ -214,24 +232,26 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); - return this._createErrorResult(errMessage); + // Proxy errors (e.g., process spawn failure) are treated as warnings + return { + resultKind: 'warning', + output: undefined, + warningMessage: errMessage, + }; } } - private _createErrorResult(errorMessage: string): IHookResult { - return { - resultKind: 'error', - output: errorMessage, - }; - } - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { switch (commandResult.kind) { case HookCommandResultKind.Error: { - // Blocking error - shown to model - return this._createErrorResult( - typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result) - ); + // Exit code 2 - stop processing with message shown to user (not model) + // Equivalent to continue=false with stopReason=stderr + const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); + return { + resultKind: 'error', + stopReason: message, + output: undefined, + }; } case HookCommandResultKind.NonBlockingError: { // Non-blocking error - shown to user only as warning @@ -259,16 +279,27 @@ export class HooksExecutionService extends Disposable implements IHooksExecution const resultObj = commandResult.result as Record; const hookOutput = this._extractHookSpecificOutput(resultObj); + // Handle continue field: when false, stopReason is effective + // stopReason takes precedence if both are set + let stopReason = commonFields.stopReason; + if (commonFields.continue === false && !stopReason) { + stopReason = ''; // Empty string signals stop without a specific reason + } + return { resultKind: 'success', - stopReason: commonFields.stopReason, + stopReason, warningMessage: commonFields.systemMessage, output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, }; } default: { - // Unexpected result kind - treat as blocking error - return this._createErrorResult(`Unexpected hook command result kind: ${commandResult.kind}`); + // Unexpected result kind - treat as warning + return { + resultKind: 'warning', + warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, + output: undefined, + }; } } } @@ -277,7 +308,7 @@ export class HooksExecutionService extends Disposable implements IHooksExecution * Extract hook-specific output fields, excluding common fields. */ private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['stopReason', 'systemMessage']); + const commonFields = new Set(['continue', 'stopReason', 'systemMessage']); const output: Record = {}; for (const [key, value] of Object.entries(result)) { if (value !== undefined && !commonFields.has(key)) { @@ -320,6 +351,35 @@ export class HooksExecutionService extends Disposable implements IHooksExecution return undefined; } + /** + * Emit a hook progress event to show warnings or stop reasons to the user. + */ + private _emitHookProgress(hookType: HookTypeValue, sessionResource: URI, stopReason?: string, systemMessage?: string): void { + this._onDidHookProgress.fire({ + hookType, + sessionResource, + stopReason, + systemMessage, + }); + } + + /** + * Collect all warning messages from hook results and emit them as a single aggregated progress event. + * Uses numbered list formatting when there are multiple warnings. + */ + private _emitAggregatedWarnings(hookType: HookTypeValue, sessionResource: URI, results: readonly IHookResult[]): void { + const warnings = results + .filter(r => r.warningMessage !== undefined) + .map(r => r.warningMessage!); + + if (warnings.length > 0) { + const message = warnings.length === 1 + ? warnings[0] + : warnings.map((w, i) => `${i + 1}. ${w}`).join('\n'); + this._emitHookProgress(hookType, sessionResource, undefined, message); + } + } + registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { const key = sessionResource.toString(); this._sessionHooks.set(key, hooks); @@ -447,11 +507,18 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } - if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) { - return undefined; + const baseResult = winningResult ?? results[0]; + + // Emit hook progress for warning messages after all hooks have completed + this._emitAggregatedWarnings(HookType.PreToolUse, sessionResource, results); + + // If any hook set stopReason, throw HookAbortError after processing + const stoppedResult = results.find(r => r.stopReason !== undefined); + if (stoppedResult?.stopReason !== undefined) { + this._emitHookProgress(HookType.PreToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); + throw new HookAbortError(HookType.PreToolUse, stoppedResult.stopReason ?? 'Unknown error'); } - const baseResult = winningResult ?? results[0]; return { ...baseResult, permissionDecision: mostRestrictiveDecision, @@ -529,12 +596,18 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } - // Return combined result if there's a block decision or any additional context - if (!hasBlock && allAdditionalContext.length === 0) { - return undefined; + const baseResult = blockResult ?? results[0]; + + // Emit hook progress for warning messages after all hooks have completed + this._emitAggregatedWarnings(HookType.PostToolUse, sessionResource, results); + + // If any hook set stopReason, throw HookAbortError after processing + const stoppedResult = results.find(r => r.stopReason !== undefined); + if (stoppedResult?.stopReason !== undefined) { + this._emitHookProgress(HookType.PostToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); + throw new HookAbortError(HookType.PostToolUse, stoppedResult.stopReason ?? 'Unknown error'); } - const baseResult = blockResult ?? results[0]; return { ...baseResult, decision: hasBlock ? 'block' : undefined, @@ -543,3 +616,29 @@ export class HooksExecutionService extends Disposable implements IHooksExecution }; } } + +/** + * Error thrown when a hook requests the agent to abort processing. + * The message should be shown to the user. + */ +export class HookAbortError extends Error { + constructor( + public readonly hookType: string, + public readonly stopReason: string + ) { + super(`Hook ${hookType} aborted: ${stopReason}`); + this.name = 'HookAbortError'; + } +} + +/** + * Formats a localized error message for a failed hook. + * @param errorMessage The error message from the hook + * @returns A localized error message string + */ +export function formatHookErrorMessage(errorMessage: string): string { + if (errorMessage) { + return localize('hookFatalErrorWithMessage', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details. Error message: {0}', errorMessage); + } + return localize('hookFatalError', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details.'); +} diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts index 77368770734..f35308d8935 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts @@ -16,7 +16,7 @@ * External types (in hooksCommandTypes.ts) define the contract with spawned commands. */ -import { vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js'; +import { vBoolean, vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js'; //#region Common Hook Types @@ -52,6 +52,7 @@ export interface IHookResult { } export const commonHookOutputValidator = vObj({ + continue: vOptionalProp(vBoolean()), stopReason: vOptionalProp(vString()), systemMessage: vOptionalProp(vString()), }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 4b6f909e845..3913649070d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -68,6 +68,7 @@ class TestTelemetryService implements Partial { class MockHooksExecutionService implements IHooksExecutionService { readonly _serviceBrand: undefined; readonly onDidExecuteHook = Event.None; + readonly onDidHookProgress = Event.None; public preToolUseHookResult: IPreToolUseHookResult | undefined = undefined; public postToolUseHookResult: IPostToolUseHookResult | undefined = undefined; public lastPreToolUseInput: IPreToolUseCallerInput | undefined = undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts index bd017e42b0b..43f2f82410d 100644 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -140,7 +140,7 @@ suite('HooksExecutionService', () => { assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); }); - test('wraps proxy errors in error result', async () => { + test('wraps proxy errors in warning result', async () => { const proxy = createMockProxy(() => { throw new Error('proxy failed'); }); @@ -152,9 +152,10 @@ suite('HooksExecutionService', () => { const results = await service.executeHook(HookType.PreToolUse, sessionUri); assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].output, 'proxy failed'); - // Error results still have default common fields + // Proxy errors are now treated as warnings (non-blocking) + assert.strictEqual(results[0].resultKind, 'warning'); + assert.strictEqual(results[0].warningMessage, 'proxy failed'); + assert.strictEqual(results[0].output, undefined); assert.strictEqual(results[0].stopReason, undefined); }); @@ -217,6 +218,48 @@ suite('HooksExecutionService', () => { assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); }); + test('handles continue false by setting stopReason', async () => { + const proxy = createMockProxy(() => ({ + kind: HookCommandResultKind.Success, + result: { + continue: false, + systemMessage: 'User requested to stop' + } + })); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].resultKind, 'success'); + // continue:false without explicit stopReason sets stopReason to empty string + assert.strictEqual(results[0].stopReason, ''); + assert.strictEqual(results[0].warningMessage, 'User requested to stop'); + }); + + test('stopReason takes precedence over continue false', async () => { + const proxy = createMockProxy(() => ({ + kind: HookCommandResultKind.Success, + result: { + continue: false, + stopReason: 'Explicit stop reason' + } + })); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].resultKind, 'success'); + assert.strictEqual(results[0].stopReason, 'Explicit stop reason'); + }); + test('uses defaults when no common fields present', async () => { const proxy = createMockProxy(() => ({ kind: HookCommandResultKind.Success, @@ -239,7 +282,7 @@ suite('HooksExecutionService', () => { assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); }); - test('handles error results from command', async () => { + test('handles error results from command (exit code 2) as stop with stopReason', async () => { const proxy = createMockProxy(() => ({ kind: HookCommandResultKind.Error, result: 'command failed with error' @@ -252,10 +295,10 @@ suite('HooksExecutionService', () => { const results = await service.executeHook(HookType.PreToolUse, sessionUri); assert.strictEqual(results.length, 1); + // Exit code 2 produces error with stopReason assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].output, 'command failed with error'); - // Defaults are still applied - assert.strictEqual(results[0].stopReason, undefined); + assert.strictEqual(results[0].stopReason, 'command failed with error'); + assert.strictEqual(results[0].output, undefined); }); test('handles non-blocking error results from command', async () => { From 70c81130fb21b84e882757ec8ede17ac1633108d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 9 Feb 2026 18:54:46 -0600 Subject: [PATCH 15/64] add context menu actions to tips (#294002) fixes #293507 --- src/vs/platform/actions/common/actions.ts | 1 + .../contrib/chat/browser/chatTipService.ts | 75 ++++++++++++-- .../chatContentParts/chatTipContentPart.ts | 99 ++++++++++++++++++- .../chat/browser/widget/chatListRenderer.ts | 17 +++- .../chat/test/browser/chatTipService.test.ts | 64 ++++++++++++ 5 files changed, 244 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 7c470fb6cca..a81fe449d62 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -273,6 +273,7 @@ export class MenuId { static readonly ChatEditingCodeBlockContext = new MenuId('ChatEditingCodeBlockContext'); static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu'); static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext'); + static readonly ChatTipContext = new MenuId('ChatTipContext'); static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar'); static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu'); static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 0f80b97a1d5..309d5b366f2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter, Event } from '../../../../base/common/event.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { localize } from '../../../../nls.js'; -import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../common/constants.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -16,6 +16,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { localize } from '../../../../nls.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -27,6 +28,16 @@ export interface IChatTip { export interface IChatTipService { readonly _serviceBrand: undefined; + /** + * Fired when the current tip is dismissed. + */ + readonly onDidDismissTip: Event; + + /** + * Fired when tips are disabled. + */ + readonly onDidDisableTips: Event; + /** * Gets a tip to show for a request, or undefined if a tip has already been shown this session. * Only one tip is shown per VS Code session (resets on reload). @@ -36,6 +47,17 @@ export interface IChatTipService { * @param contextKeyService The context key service to evaluate tip eligibility. */ getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Dismisses the current tip and allows a new one to be picked for the same request. + * The dismissed tip will not be shown again in this workspace. + */ + dismissTip(): void; + + /** + * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. + */ + disableTips(): void; } export interface ITipDefinition { @@ -255,6 +277,12 @@ export class TipEligibilityTracker extends Disposable { export class ChatTipService extends Disposable implements IChatTipService { readonly _serviceBrand: undefined; + private readonly _onDidDismissTip = this._register(new Emitter()); + readonly onDidDismissTip = this._onDidDismissTip.event; + + private readonly _onDidDisableTips = this._register(new Emitter()); + readonly onDidDisableTips = this._onDidDisableTips.event; + /** * Timestamp when this service was instantiated. * Used to only show tips for requests created after this time. @@ -277,11 +305,13 @@ export class ChatTipService extends Disposable implements IChatTipService { */ private _shownTip: ITipDefinition | undefined; + private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; private readonly _tracker: TipEligibilityTracker; constructor( @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IStorageService private readonly _storageService: IStorageService, @ICommandService commandService: ICommandService, @IStorageService storageService: IStorageService, @IPromptsService promptsService: IPromptsService, @@ -292,6 +322,39 @@ export class ChatTipService extends Disposable implements IChatTipService { )); } + dismissTip(): void { + if (this._shownTip) { + const dismissed = this._getDismissedTipIds(); + dismissed.push(this._shownTip.id); + this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + this._hasShownTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + } + + private _getDismissedTipIds(): string[] { + const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.WORKSPACE); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + disableTips(): void { + this._hasShownTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._configurationService.updateValue('chat.tips.enabled', false); + this._onDidDisableTips.fire(); + } + getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined { // Check if tips are enabled if (!this._configurationService.getValue('chat.tips.enabled')) { @@ -319,12 +382,12 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Find eligible tips (excluding dismissed ones) + const dismissedIds = new Set(this._getDismissedTipIds()); + const eligibleTips = TIP_CATALOG.filter(tip => !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)); // Record the current mode for future eligibility decisions this._tracker.recordCurrentMode(contextKeyService); - // Find eligible tips - const eligibleTips = TIP_CATALOG.filter(tip => this._isEligible(tip, contextKeyService)); - if (eligibleTips.length === 0) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 3c3fab695f7..0e71dfec5c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -5,26 +5,117 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../../../nls.js'; +import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatTip } from '../../chatTipService.js'; +import { IChatTip, IChatTipService } from '../../chatTipService.js'; const $ = dom.$; export class ChatTipContentPart extends Disposable { public readonly domNode: HTMLElement; + private readonly _onDidHide = this._register(new Emitter()); + public readonly onDidHide = this._onDidHide.event; + + private readonly _renderedContent = this._register(new MutableDisposable()); + constructor( tip: IChatTip, - renderer: IMarkdownRenderer, + private readonly _renderer: IMarkdownRenderer, + private readonly _chatTipService: IChatTipService, + private readonly _contextMenuService: IContextMenuService, + private readonly _menuService: IMenuService, + private readonly _contextKeyService: IContextKeyService, + private readonly _getNextTip: () => IChatTip | undefined, ) { super(); this.domNode = $('.chat-tip-widget'); + this._renderTip(tip); + + this._register(this._chatTipService.onDidDismissTip(() => { + const nextTip = this._getNextTip(); + if (nextTip) { + this._renderTip(nextTip); + } else { + this._onDidHide.fire(); + } + })); + + this._register(this._chatTipService.onDidDisableTips(() => { + this._onDidHide.fire(); + })); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => { + dom.EventHelper.stop(e, true); + const event = new StandardMouseEvent(dom.getWindow(this.domNode), e); + this._contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions: () => { + const menu = this._menuService.getMenuActions(MenuId.ChatTipContext, this._contextKeyService); + return getFlatContextMenuActions(menu); + }, + }); + })); + } + + private _renderTip(tip: IChatTip): void { + dom.clearNode(this.domNode); this.domNode.appendChild(renderIcon(Codicon.lightbulb)); - const markdownContent = this._register(renderer.render(tip.content)); + const markdownContent = this._renderer.render(tip.content); + this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); } } + +//#region Tip context menu actions + +registerAction2(class DismissTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.dismissTip', + title: localize2('chatTip.dismiss', "Dismiss this tip"), + f1: false, + menu: [{ + id: MenuId.ChatTipContext, + group: 'chatTip', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).dismissTip(); + } +}); + +registerAction2(class DisableTipsAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.disableTips', + title: localize2('chatTip.disableTips', "Disable tips"), + f1: false, + menu: [{ + id: MenuId.ChatTipContext, + group: 'chatTip', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).disableTips(); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 148f548ca4f..0d812eb533f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -34,7 +34,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; -import { MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { MenuId, MenuItemAction, IMenuService } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -254,6 +254,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), + ); templateData.value.appendChild(tipPart.domNode); templateData.elementDisposables.add(tipPart); + templateData.elementDisposables.add(tipPart.onDidHide(() => { + tipPart.domNode.remove(); + })); } let inlineSlashCommandRendered = false; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 849bed14cdf..5da31f55818 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -158,6 +158,70 @@ suite('ChatTipService', () => { assert.ok(tip, 'New request should get a tip after multiple old requests'); }); + test('dismissTip excludes the dismissed tip and allows a new one', () => { + const service = createService(); + const now = Date.now(); + + // Get a tip + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1); + + // Dismiss it + service.dismissTip(); + + // Next call should return a different tip (since the dismissed one is excluded) + const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + if (tip2) { + assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be shown again'); + } + // tip2 may be undefined if it was the only eligible tip — that's also valid + }); + + test('dismissTip fires onDidDismissTip event', () => { + const service = createService(); + const now = Date.now(); + + service.getNextTip('request-1', now + 1000, contextKeyService); + + let fired = false; + testDisposables.add(service.onDidDismissTip(() => { fired = true; })); + service.dismissTip(); + + assert.ok(fired, 'onDidDismissTip should fire'); + }); + + test('disableTips fires onDidDisableTips event', () => { + const service = createService(); + const now = Date.now(); + + service.getNextTip('request-1', now + 1000, contextKeyService); + + let fired = false; + testDisposables.add(service.onDidDisableTips(() => { fired = true; })); + service.disableTips(); + + assert.ok(fired, 'onDidDisableTips should fire'); + }); + + test('disableTips resets state so re-enabling works', () => { + const service = createService(); + const now = Date.now(); + + // Show a tip + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1); + + // Disable tips + service.disableTips(); + + // Re-enable tips + configurationService.setUserConfiguration('chat.tips.enabled', true); + + // Should be able to get a tip again on a new request + const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); + assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); + }); + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { const tip: ITipDefinition = { id: 'tip.undoChanges', From b783405ff10bc4b1c524354557c5d40b37bd3d9e Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:57:43 -0800 Subject: [PATCH 16/64] Use chat sessions controllers for extHost <-> mainThread Continues adopting the controller model (push based) in core. A bit messy since lots of code in core is using read/refresh interchangeably right now --- .../api/browser/mainThreadChatSessions.ts | 158 ++++++++++-------- .../workbench/api/common/extHost.protocol.ts | 7 +- .../api/common/extHostChatSessions.ts | 137 +++++++-------- .../browser/mainThreadChatSessions.test.ts | 4 +- .../agentSessions/agentSessionsModel.ts | 13 +- .../chatSessions/chatSessions.contribution.ts | 101 +++++------ .../chat/common/chatSessionsService.ts | 7 +- .../agentSessionViewModel.test.ts | 15 +- .../test/common/mockChatSessionsService.ts | 23 ++- 9 files changed, 250 insertions(+), 215 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 27dfd835f3e..797db7cff68 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -5,8 +5,7 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { isCancellationError } from '../../../base/common/errors.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; @@ -321,12 +320,47 @@ export class ObservableChatSession extends Disposable implements IChatSession { } } +class MainThreadChatSessionItemController extends Disposable implements IChatSessionItemController { + + private readonly _proxy: ExtHostChatSessionsShape; + private readonly _handle: number; + + private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); + readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + + constructor( + proxy: ExtHostChatSessionsShape, + handle: number + ) { + super(); + this._proxy = proxy; + this._handle = handle; + } + + private _items: IChatSessionItem[] = []; + get items(): IChatSessionItem[] { + return this._items; + } + + refresh(token: CancellationToken): Promise { + return this._proxy.$refreshChatSessionItems(this._handle, token); + } + + setItems(items: IChatSessionItem[]): void { + this._items = items; + this._onDidChangeChatSessionItems.fire(); + } + + fireOnDidChangeChatSessionItems(): void { + this._onDidChangeChatSessionItems.fire(); + } +} + @extHostNamedCustomer(MainContext.MainThreadChatSessions) export class MainThreadChatSessions extends Disposable implements MainThreadChatSessionsShape { private readonly _itemControllerRegistrations = this._register(new DisposableMap; + readonly controller: MainThreadChatSessionItemController; }>()); private readonly _contentProvidersRegistrations = this._register(new DisposableMap()); private readonly _sessionTypeToHandle = new Map(); @@ -375,51 +409,69 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return this._sessionTypeToHandle.get(chatSessionType); } - $registerChatSessionItemProvider(handle: number, chatSessionType: string): void { - // Register the provider handle - this tracks that a provider exists + $registerChatSessionItemController(handle: number, chatSessionType: string): void { + // Register the controller handle - items will be pushed via $setChatSessionItems const disposables = new DisposableStore(); - const changeEmitter = disposables.add(new Emitter()); - - const self = this; - - const controller = new class implements IChatSessionItemController { - - items: IChatSessionItem[] = []; - - get onDidChangeChatSessionItems() { - return changeEmitter.event; - } - - async refresh(token: CancellationToken): Promise { - try { - this.items = await self._provideChatSessionItems(handle, token); - } catch (err) { - if (isCancellationError(err)) { - return; - } - throw err; - } - } - }; + const controller = new MainThreadChatSessionItemController(this._proxy, handle); + disposables.add(controller); disposables.add(this._chatSessionsService.registerChatSessionItemController(chatSessionType, controller)); this._itemControllerRegistrations.set(handle, { dispose: () => disposables.dispose(), chatSessionType, controller, - onDidChangeItems: changeEmitter, }); disposables.add(this._chatSessionsService.registerChatModelChangeListeners( this._chatService, chatSessionType, - () => changeEmitter.fire() + () => controller.fireOnDidChangeChatSessionItems() )); } $onDidChangeChatSessionItems(handle: number): void { - this._itemControllerRegistrations.get(handle)?.onDidChangeItems.fire(); + this._itemControllerRegistrations.get(handle)?.controller.fireOnDidChangeChatSessionItems(); + } + + async $setChatSessionItems(handle: number, items: Dto[]): Promise { + const registration = this._itemControllerRegistrations.get(handle); + if (!registration) { + this._logService.warn(`No controller registered for handle ${handle}`); + return; + } + + const resolvedItems = await Promise.all(items.map(async item => { + const uri = URI.revive(item.resource); + const model = this._chatService.getSession(uri); + if (model) { + item = await this.handleSessionModelOverrides(model, item); + } + + // We can still get stats if there is no model or if fetching from model failed + if (!item.changes || !model) { + const stats = (await this._chatService.getMetadataForSession(uri))?.stats; + const diffs: IAgentSession['changes'] = { + files: stats?.fileCount || 0, + insertions: stats?.added || 0, + deletions: stats?.removed || 0 + }; + if (hasValidDiff(diffs)) { + item.changes = diffs; + } + } + + return { + ...item, + changes: revive(item.changes), + resource: uri, + iconPath: item.iconPath, + tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, + archived: item.archived, + } satisfies IChatSessionItem; + })); + + registration.controller.setItems(resolvedItems); } $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { @@ -503,46 +555,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } - private async _provideChatSessionItems(handle: number, token: CancellationToken): Promise { - try { - // Get all results as an array from the RPC call - const sessions = await this._proxy.$provideChatSessionItems(handle, token); - return Promise.all(sessions.map(async session => { - const uri = URI.revive(session.resource); - const model = this._chatService.getSession(uri); - if (model) { - session = await this.handleSessionModelOverrides(model, session); - } - - // We can still get stats if there is no model or if fetching from model failed - if (!session.changes || !model) { - const stats = (await this._chatService.getMetadataForSession(uri))?.stats; - // TODO: we shouldn't be converting this, the types should match - const diffs: IAgentSession['changes'] = { - files: stats?.fileCount || 0, - insertions: stats?.added || 0, - deletions: stats?.removed || 0 - }; - if (hasValidDiff(diffs)) { - session.changes = diffs; - } - } - - return { - ...session, - changes: revive(session.changes), - resource: uri, - iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, - archived: session.archived, - } satisfies IChatSessionItem; - })); - } catch (error) { - this._logService.error('Error providing chat sessions:', error); - } - return []; - } - private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { // Override desciription if there's an in-progress count const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); @@ -611,7 +623,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } - $unregisterChatSessionItemProvider(handle: number): void { + $unregisterChatSessionItemController(handle: number): void { this._itemControllerRegistrations.deleteAndDispose(handle); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0d23cba7e36..ed17ae0c122 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3428,8 +3428,9 @@ export interface IChatSessionProviderOptions { } export interface MainThreadChatSessionsShape extends IDisposable { - $registerChatSessionItemProvider(handle: number, chatSessionType: string): void; - $unregisterChatSessionItemProvider(handle: number): void; + $registerChatSessionItemController(handle: number, chatSessionType: string): void; + $unregisterChatSessionItemController(handle: number): void; + $setChatSessionItems(handle: number, items: Dto[]): void; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; @@ -3443,7 +3444,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { } export interface ExtHostChatSessionsShape { - $provideChatSessionItems(providerHandle: number, token: CancellationToken): Promise[]>; + $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index e900c111d9e..e4df052cb3f 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -9,7 +9,7 @@ import { coalesce } from '../../../base/common/arrays.js'; import { DeferredPromise } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; @@ -257,17 +257,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private readonly _proxy: Proxied; - private _itemProviderHandlePool = 0; - private readonly _chatSessionItemProviders = new Map(); - private _itemControllerHandlePool = 0; private readonly _chatSessionItemControllers = new Map()); + + + // Helper to fetch and push items to main thread + const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { + const convertedItems: IChatSessionItem[] = []; + for (const sessionContent of items) { + this._sessionItems.set(sessionContent.resource, sessionContent); + convertedItems.push(this.convertChatSessionItem(sessionContent)); + } + this._proxy.$setChatSessionItems(handle, convertedItems); + }; + + const controller: vscode.ChatSessionItemController = { + id: chatSessionType, + get items(): vscode.ChatSessionItemCollection { + throw new Error('not implemented'); + }, + createChatSessionItem: (_resource: vscode.Uri, _label: string) => { + throw new Error('not implemented'); + }, + onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, + dispose: () => { + disposables.dispose(); + }, + refreshHandler: async (token: vscode.CancellationToken) => { + const items = await provider.provideChatSessionItems(token) ?? []; + updateItems(items); + }, + }; + + this._chatSessionItemControllers.set(handle, { chatSessionType: chatSessionType, controller, extension, disposable: disposables, onDidChangeChatSessionItemStateEmitter }); + this._proxy.$registerChatSessionItemController(handle, chatSessionType); + if (provider.onDidChangeChatSessionItems) { disposables.add(provider.onDidChangeChatSessionItems(() => { - this._logService.trace(`ExtHostChatSessions. Firing $onDidChangeChatSessionItems for ${chatSessionType}`); + this._logService.trace(`ExtHostChatSessions. Provider items changed for ${chatSessionType}`); this._proxy.$onDidChangeChatSessionItems(handle); })); } @@ -347,35 +372,30 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return { dispose: () => { - this._chatSessionItemProviders.delete(handle); + this._chatSessionItemControllers.delete(handle); disposables.dispose(); - this._proxy.$unregisterChatSessionItemProvider(handle); + this._proxy.$unregisterChatSessionItemController(handle); } }; } - createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: (token: vscode.CancellationToken) => Thenable): vscode.ChatSessionItemController { const controllerHandle = this._itemControllerHandlePool++; const disposables = new DisposableStore(); let isDisposed = false; - let refreshIdPool = 0; - let activeRefreshId: number | undefined = undefined; - - const onDidChangeItemsEmitter = disposables.add(new Emitter()); const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - const notifyItemsChanged = () => { - // Suppress updates when a refresh is already happening - if (typeof activeRefreshId === 'undefined') { - onDidChangeItemsEmitter.fire(); + const onItemsChanged = () => { + const items: IChatSessionItem[] = []; + for (const [_, item] of collection) { + this._sessionItems.set(item.resource, item); + items.push(this.convertChatSessionItem(item)); } + this._proxy.$setChatSessionItems(controllerHandle, items); }; - const collection = new ChatSessionItemCollectionImpl(() => { - notifyItemsChanged(); - }); + const collection = new ChatSessionItemCollectionImpl(onItemsChanged); const controller = Object.freeze({ id, @@ -384,17 +404,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('ChatSessionItemController has been disposed'); } - const opId = ++refreshIdPool; - activeRefreshId = opId; - - try { - this._logService.trace(`ExtHostChatSessions. Controller(${id}).refresh()`); - await refreshHandler(refreshToken); - } finally { - if (activeRefreshId === opId) { - activeRefreshId = undefined; - } - } + this._logService.trace(`ExtHostChatSessions. Controller(${id}).refresh()`); + await refreshHandler(refreshToken); }, items: collection, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, @@ -405,7 +416,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return new ChatSessionItemImpl(resource, label, () => { // TODO: Optimize to only update the specific item - notifyItemsChanged(); + onItemsChanged(); }); }, dispose: () => { @@ -414,21 +425,14 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }); - this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id, onDidChangeChatSessionItemStateEmitter }); + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, chatSessionType: id, onDidChangeChatSessionItemStateEmitter }); - // Controllers are implemented using providers on the ext host side for now - disposables.add(this.registerChatSessionItemProvider(extension, id, { - onDidChangeChatSessionItems: onDidChangeItemsEmitter.event, - onDidCommitChatSessionItem: Event.None, - provideChatSessionItems: async (token: CancellationToken): Promise => { - await controller.refreshHandler(token); - return Array.from(controller.items, x => x[1]); - }, - })); + // Register the controller with the main thread + this._proxy.$registerChatSessionItemController(controllerHandle, id); disposables.add(toDisposable(() => { this._chatSessionItemControllers.delete(controllerHandle); - this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + this._proxy.$unregisterChatSessionItemController(controllerHandle); })); return controller; @@ -503,27 +507,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const itemProvider = this._chatSessionItemProviders.get(handle); - if (!itemProvider) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } - - this._logService.trace(`ExtHostChatSessions:$provideChatSessionItems(${itemProvider.sessionType})`); - const items = await itemProvider.provider.provideChatSessionItems(token) ?? []; - if (token.isCancellationRequested) { - return []; - } - - const response: IChatSessionItem[] = []; - for (const sessionContent of items) { - this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(sessionContent)); - } - return response; - } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { @@ -785,6 +768,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } + async $refreshChatSessionItems(handle: number, token: CancellationToken): Promise { + const controllerData = this._chatSessionItemControllers.get(handle); + if (!controllerData) { + this._logService.warn(`No controller found for handle ${handle}`); + return; + } + + await controllerData.controller.refreshHandler(token); + } + $onDidChangeChatSessionItemState(controllerHandle: number, sessionResourceComponents: UriComponents, archived: boolean): void { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index fdb8e734540..2f846d3c7fb 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -60,7 +60,7 @@ suite('ObservableChatSession', function () { $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), - $provideChatSessionItems: sinon.stub(), + $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), }; }); @@ -357,7 +357,7 @@ suite('MainThreadChatSessions', function () { $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), - $provideChatSessionItems: sinon.stub(), + $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), }; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9757d370d55..37a75d93f19 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -428,7 +428,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode // Sessions changes this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.resolve(chatSessionType))); + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.updateItems([chatSessionType], CancellationToken.None))); // State this._register(this.storageService.onWillSaveState(() => { @@ -468,12 +468,21 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode const providersToResolve = Array.from(this.providersToResolve); this.providersToResolve.clear(); + const providerFilter = providersToResolve.includes(undefined) ? undefined : coalesce(providersToResolve); + + await this.chatSessionsService.refreshChatSessionItems(providerFilter, token); + await this.updateItems(providerFilter, token); + } + + /** + * Update the sessions by fetching from the service. This does not trigger an explicit refresh + */ + private async updateItems(providerFilter: readonly string[] | undefined, token: CancellationToken): Promise { const mapSessionContributionToType = new Map(); for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { mapSessionContributionToType.set(contribution.type, contribution); } - const providerFilter = providersToResolve.includes(undefined) ? undefined : coalesce(providersToResolve); const providerResults = await this.chatSessionsService.getChatSessionItems(providerFilter, token); const resolvedProviders = new Set(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index b28871666e1..88889626e90 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -5,7 +5,7 @@ import { sep } from '../../../../../base/common/path.js'; import { raceCancellationError } from '../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -45,7 +45,7 @@ import { IViewsService } from '../../../../services/views/common/viewsService.js import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; -import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; @@ -251,7 +251,7 @@ class ContributedChatSessionData extends Disposable { export class ChatSessionsService extends Disposable implements IChatSessionsService { readonly _serviceBrand: undefined; - private readonly _itemControllers = new Map(); + private readonly _itemControllers = new Map }>(); private readonly _contributions: Map = new Map(); private readonly _contributionDisposables = this._register(new DisposableMap()); @@ -735,7 +735,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ await this.doActivateChatSessionItemController(chatViewType); } - private async doActivateChatSessionItemController(chatViewType: string): Promise<{ resolvedType: string; controller: IChatSessionItemController } | undefined> { + private async doActivateChatSessionItemController(chatViewType: string): Promise { await this._extensionService.whenInstalledExtensionsRegistered(); const resolvedType = this._resolveToPrimaryType(chatViewType); if (resolvedType) { @@ -744,17 +744,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const contribution = this._contributions.get(chatViewType)?.contribution; if (contribution && !this._isContributionAvailable(contribution)) { - return undefined; + return false; } if (this._itemControllers.has(chatViewType)) { - return { resolvedType: chatViewType, controller: this._itemControllers.get(chatViewType)! }; + return true; } await this._extensionService.activateByEvent(`onChatSession:${chatViewType}`); const controller = this._itemControllers.get(chatViewType)!; - return controller && { resolvedType: chatViewType, controller }; + return !!controller; } async canResolveChatSession(chatSessionResource: URI) { @@ -773,67 +773,70 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._contentProviders.has(chatSessionResource.scheme); } - public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { - const results: Array<{ readonly chatSessionType: string; readonly items: readonly IChatSessionItem[] }> = []; - const resolvedProviderTypes = new Set(); - - // First, iterate over extension point contributions - for (const contrib of this.getAllChatSessionContributions()) { + private async tryActivateControllers(providersToResolve: readonly string[] | undefined): Promise { + await Promise.all(this.getAllChatSessionContributions().map(async (contrib) => { if (providersToResolve && !providersToResolve.includes(contrib.type)) { - continue; // skip: not considered for resolving + return; // skip: not considered for resolving } - const controllerEntry = await this.doActivateChatSessionItemController(contrib.type); - if (!controllerEntry) { + if (!await this.doActivateChatSessionItemController(contrib.type)) { // We requested this provider but it is not available if (providersToResolve?.includes(contrib.type)) { this._logService.trace(`[ChatSessionsService] No enabled provider found for chat session type ${contrib.type}`); } - continue; } + })); + } + public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { + // First, make sure contributed controller are active + await this.tryActivateControllers(providersToResolve); + + // Then actually resolve items for all active controllers + const results: Array<{ readonly chatSessionType: string; readonly items: readonly IChatSessionItem[] }> = []; + await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { + const resolvedType = this._resolveToPrimaryType(chatSessionType) ?? chatSessionType; try { - await raceCancellationError(controllerEntry.controller.refresh(token), token); + await controllerEntry.initialRefresh; // Ensure initial refresh is complete before accessing items + const providerSessions = controllerEntry.controller.items; - this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${controllerEntry.resolvedType}`); - results.push({ chatSessionType: controllerEntry.resolvedType, items: providerSessions }); - resolvedProviderTypes.add(controllerEntry.resolvedType); - } catch (error) { - // Log error but continue with other providers - this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${controllerEntry.resolvedType}`, error); - continue; + this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${resolvedType}`); + results.push({ chatSessionType: resolvedType, items: providerSessions }); + } catch (err) { + if (!isCancellationError(err)) { + // Log error but continue with other providers + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${resolvedType}`, err); + } } - } - - // Also include registered items providers that don't have corresponding contributions - // (e.g., the local session provider which is built-in and not an extension contribution) - for (const [chatSessionType, controller] of this._itemControllers) { - if (resolvedProviderTypes.has(chatSessionType)) { - continue; // already resolved via contribution - } - if (providersToResolve && !providersToResolve.includes(chatSessionType)) { - continue; // skip: not considered for resolving - } - - try { - await raceCancellationError(controller.refresh(token), token); - const providerSessions = controller.items; - this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for built-in provider ${chatSessionType}`); - results.push({ chatSessionType, items: providerSessions }); - } catch (error) { - this._logService.error(`[ChatSessionsService] Failed to resolve sessions for built-in provider ${chatSessionType}`, error); - continue; - } - } + })); return results; } + public async refreshChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise { + await this.tryActivateControllers(providersToResolve); + + await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { + try { + await controllerEntry.controller.refresh(token); + } catch (err) { + if (!isCancellationError(err)) { + // Log error but continue with other providers + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${chatSessionType}`, err); + } + } + })); + } + registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable { - this._itemControllers.set(chatSessionType, controller); + const disposables = new DisposableStore(); + + + // Register and trigger an initial refresh to populate the provider's items + const initialRefreshCts = disposables.add(new CancellationTokenSource()); + this._itemControllers.set(chatSessionType, { controller, initialRefresh: controller.refresh(initialRefreshCts.token) }); this._onDidChangeItemsProviders.fire({ chatSessionType }); - const disposables = new DisposableStore(); disposables.add(controller.onDidChangeChatSessionItems(() => { this._onDidChangeSessionItems.fire({ chatSessionType }); })); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index caa8538f4d3..9f280a98fb8 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -228,11 +228,16 @@ export interface IChatSessionsService { getInputPlaceholderForSessionType(chatSessionType: string): string | undefined; /** - * Get the list of chat session items grouped by session type. + * Get the list of current chat session items grouped by session type. * @param providerTypeFilter If specified, only returns items from the given providers. If undefined, returns items from all providers. */ getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; + /** + * Forces the controllers to refresh their session items, optionally filtered by provider type. + */ + refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise; + reportInProgress(chatSessionType: string, count: number): void; getInProgress(): { displayName: string; count: number }[]; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index e1340d8927a..77685cafa97 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -532,6 +532,9 @@ suite('AgentSessions', () => { }; mockChatSessionsService.registerChatSessionItemController('test-type', controller); + // Registering calls a refresh initially + assert.strictEqual(controllerCallCount, 1); + viewModel = createViewModel(); // Make multiple rapid resolve calls @@ -543,8 +546,8 @@ suite('AgentSessions', () => { await Promise.all(resolvePromises); - // Should only call controller once due to throttling - assert.strictEqual(controllerCallCount, 1); + // Should only call controller once more due to throttling + assert.strictEqual(controllerCallCount, 2); assert.strictEqual(viewModel.sessions.length, 1); }); }); @@ -590,8 +593,8 @@ suite('AgentSessions', () => { // First resolve all await viewModel.resolve(undefined); assert.strictEqual(viewModel.sessions.length, 2); - assert.strictEqual(controller1CallCount, 1); - assert.strictEqual(controller2CallCount, 1); + assert.strictEqual(controller1CallCount, 2); // One from registration and one from resolve + assert.strictEqual(controller2CallCount, 2); // One from registration and one from resolve // Now resolve only type-2 await viewModel.resolve('type-2'); @@ -599,9 +602,9 @@ suite('AgentSessions', () => { // Should still have only one session assert.strictEqual(viewModel.sessions.length, 1); // Controller 1 should not be called again - assert.strictEqual(controller1CallCount, 1); + assert.strictEqual(controller1CallCount, 2); // Controller 2 should be called again - assert.strictEqual(controller2CallCount, 2); + assert.strictEqual(controller2CallCount, 3); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 82d8f827d77..097866e2e4d 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -41,7 +41,7 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onRequestNotifyExtension = new AsyncEmitter(); readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; - private sessionItemControllers = new Map(); + private sessionItemControllers = new Map }>(); private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); @@ -67,7 +67,7 @@ export class MockChatSessionsService implements IChatSessionsService { } registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable { - this.sessionItemControllers.set(chatSessionType, controller); + this.sessionItemControllers.set(chatSessionType, { controller, initialRefresh: controller.refresh(CancellationToken.None) }); return { dispose: () => { this.sessionItemControllers.delete(chatSessionType); @@ -108,19 +108,28 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.inputPlaceholder; } - getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { + getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise> { return Promise.all( Array.from(this.sessionItemControllers.entries()) - .filter(([chatSessionType]) => !providersToResolve || providersToResolve.includes(chatSessionType)) - .map(async ([chatSessionType, controller]) => { - await controller.refresh(token); + .filter(([chatSessionType]) => !providerTypeFilter || providerTypeFilter.includes(chatSessionType)) + .map(async ([chatSessionType, controllerEntry]) => { + await controllerEntry.initialRefresh; // ensure initial refresh is done return ({ chatSessionType: chatSessionType, - items: controller.items + items: controllerEntry.controller.items }); })); } + async refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise { + await Promise.all( + Array.from(this.sessionItemControllers.entries()) + .filter(([chatSessionType]) => !providerTypeFilter || providerTypeFilter.includes(chatSessionType)) + .map(async ([_chatSessionType, controllerEntry]) => { + await controllerEntry.controller.refresh(token); + })); + } + reportInProgress(chatSessionType: string, count: number): void { this.inProgress.set(chatSessionType, count); this._onDidChangeInProgress.fire(); From 2beb7b6c11150bf1dc833c52ba28b32f39f58ae0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:27:32 +0000 Subject: [PATCH 17/64] Add tunnel name telemetry to remote tunnel events (#288209) * Initial plan * Add tunnel name telemetry to remoteTunnel events Co-authored-by: pierceboggan <1091304+pierceboggan@users.noreply.github.com> * Add tunnelName to remoteConnectionSuccess telemetry event Co-authored-by: pierceboggan <1091304+pierceboggan@users.noreply.github.com> * Improve documentation for getRemoteServerRootPath Co-authored-by: pierceboggan <1091304+pierceboggan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pierceboggan <1091304+pierceboggan@users.noreply.github.com> Co-authored-by: Harald Kirschner --- src/vs/platform/remote/common/remoteHosts.ts | 24 +++++++++++++++++++ .../remoteTunnel/node/remoteTunnelService.ts | 20 ++++++++++++++++ .../remote/browser/remoteConnectionHealth.ts | 7 ++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/remote/common/remoteHosts.ts b/src/vs/platform/remote/common/remoteHosts.ts index 9583e30f1f0..f6fc4cb18fa 100644 --- a/src/vs/platform/remote/common/remoteHosts.ts +++ b/src/vs/platform/remote/common/remoteHosts.ts @@ -25,6 +25,30 @@ export function getRemoteName(authority: string | undefined): string | undefined return authority.substr(0, pos); } +/** + * Returns the suffix part of the authority after the '+' character. + * For remote connections, this is typically the server/tunnel identifier. + * Examples: + * - For tunnels: `tunnel+myTunnel` returns `myTunnel` + * - For SSH: `ssh+myserver` returns `myserver` + * - For localhost: `localhost:8000` returns `undefined` + * @param authority The remote authority string. + * @returns The suffix after the '+' character, or undefined if there is no '+' character. + */ +export function getRemoteServerRootPath(authority: string): string | undefined; +export function getRemoteServerRootPath(authority: undefined): undefined; +export function getRemoteServerRootPath(authority: string | undefined): string | undefined; +export function getRemoteServerRootPath(authority: string | undefined): string | undefined { + if (!authority) { + return undefined; + } + const pos = authority.indexOf('+'); + if (pos < 0) { + return undefined; + } + return authority.substring(pos + 1); +} + export function parseAuthorityWithPort(authority: string): { host: string; port: number } { const { host, port } = parseAuthority(authority); if (typeof port === 'undefined') { diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index bae34610f67..d85e651b231 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -28,11 +28,25 @@ type RemoteTunnelEnablementClassification = { comment: 'Reporting when Remote Tunnel access is turned on or off'; enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' }; + tunnelName?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the tunnel being enabled or disabled' }; }; type RemoteTunnelEnablementEvent = { enabled: boolean; service: boolean; + tunnelName?: string; +}; + +type RemoteTunnelConnectedClassification = { + owner: 'aeschli'; + comment: 'Reporting when a Remote Tunnel connection is established'; + tunnelName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the connected tunnel' }; + isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the connection is attached to an existing tunnel process' }; +}; + +type RemoteTunnelConnectedEvent = { + tunnelName: string; + isAttached: boolean; }; const restartTunnelOnConfigurationChanges: readonly string[] = [ @@ -241,9 +255,11 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ } private async updateTunnelProcess(): Promise { + const tunnelName = this._getTunnelName(); this.telemetryService.publicLog2('remoteTunnel.enablement', { enabled: this._mode.active, service: this._mode.active && this._mode.asService, + tunnelName, }); if (this._tunnelProcess) { @@ -396,6 +412,10 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ const m = message.match(/Open this link in your browser (https:\/\/([^\/\s]+)\/([^\/\s]+)\/([^\/\s]+))/); if (m) { const info: ConnectionInfo = { link: m[1], domain: m[2], tunnelName: m[4], isAttached }; + this.telemetryService.publicLog2('remoteTunnel.connected', { + tunnelName: info.tunnelName, + isAttached: info.isAttached, + }); this.setTunnelStatus(TunnelStates.connected(info, serviceInstallFailed)); } else if (message.match(/error refreshing token/)) { serveCommand.cancel(); diff --git a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts index a71bcec9350..937aed25b45 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts @@ -9,7 +9,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { localize } from '../../../../nls.js'; import { isWeb } from '../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { getRemoteName } from '../../../../platform/remote/common/remoteHosts.js'; +import { getRemoteName, getRemoteServerRootPath } from '../../../../platform/remote/common/remoteHosts.js'; import { IBannerService } from '../../../services/banner/browser/bannerService.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -124,16 +124,19 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont web: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Is web ui.' }; connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connected' }; remoteName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the resolver.' }; + tunnelName?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the tunnel for tunnel connections.' }; }; type RemoteConnectionSuccessEvent = { web: boolean; connectionTimeMs: number | undefined; remoteName: string | undefined; + tunnelName?: string; }; this._telemetryService.publicLog2('remoteConnectionSuccess', { web: isWeb, connectionTimeMs: await this._remoteAgentService.getConnection()?.getInitialConnectionTimeMs(), - remoteName: getRemoteName(this._environmentService.remoteAuthority) + remoteName: getRemoteName(this._environmentService.remoteAuthority), + tunnelName: getRemoteServerRootPath(this._environmentService.remoteAuthority) }); await this._measureExtHostLatency(); From e4f5e416c3e7a608ceefb08bb19214a361c70536 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:06:41 -0800 Subject: [PATCH 18/64] Code review fixes --- src/vs/workbench/api/common/extHost.protocol.ts | 2 +- src/vs/workbench/api/common/extHostChatSessions.ts | 14 ++++++++------ .../chatSessions/chatSessions.contribution.ts | 4 ++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ed17ae0c122..fff9a8d8845 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3430,7 +3430,7 @@ export interface IChatSessionProviderOptions { export interface MainThreadChatSessionsShape extends IDisposable { $registerChatSessionItemController(handle: number, chatSessionType: string): void; $unregisterChatSessionItemController(handle: number): void; - $setChatSessionItems(handle: number, items: Dto[]): void; + $setChatSessionItems(handle: number, items: Dto[]): Promise; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index e4df052cb3f..14b71d20dbb 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -324,22 +324,24 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - // Helper to fetch and push items to main thread const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { + collection.replace(items); const convertedItems: IChatSessionItem[] = []; for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); convertedItems.push(this.convertChatSessionItem(sessionContent)); } - this._proxy.$setChatSessionItems(handle, convertedItems); + void this._proxy.$setChatSessionItems(handle, convertedItems); }; + const collection = new ChatSessionItemCollectionImpl(() => { + // Noop for providers + }); + const controller: vscode.ChatSessionItemController = { id: chatSessionType, - get items(): vscode.ChatSessionItemCollection { - throw new Error('not implemented'); - }, + items: collection, createChatSessionItem: (_resource: vscode.Uri, _label: string) => { throw new Error('not implemented'); }, @@ -392,7 +394,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio this._sessionItems.set(item.resource, item); items.push(this.convertChatSessionItem(item)); } - this._proxy.$setChatSessionItems(controllerHandle, items); + void this._proxy.$setChatSessionItems(controllerHandle, items); }; const collection = new ChatSessionItemCollectionImpl(onItemsChanged); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 88889626e90..017a50df9d7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -796,6 +796,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const results: Array<{ readonly chatSessionType: string; readonly items: readonly IChatSessionItem[] }> = []; await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { const resolvedType = this._resolveToPrimaryType(chatSessionType) ?? chatSessionType; + if (providersToResolve && !providersToResolve.includes(resolvedType)) { + return; // skip: not considered for resolving + } + try { await controllerEntry.initialRefresh; // Ensure initial refresh is complete before accessing items From ff89e9d81677506777fab5b69ec3fa100870fe5e Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:09:01 -0800 Subject: [PATCH 19/64] Add explicit cancellation --- .../chat/browser/chatSessions/chatSessions.contribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 017a50df9d7..9076add61bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -851,6 +851,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return { dispose: () => { + initialRefreshCts.cancel(); disposables.dispose(); const controller = this._itemControllers.get(chatSessionType); From a63ede284f903ba304deebcdabbe41d50e7f242c Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 9 Feb 2026 18:31:09 -0800 Subject: [PATCH 20/64] Add back button to configure hooks menu (#294043) --- .../chat/browser/promptSyntax/hookActions.ts | 619 +++++++++++------- .../service/promptsServiceImpl.ts | 4 +- 2 files changed, 395 insertions(+), 228 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index c2b43e81907..c3cab3b104c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -6,6 +6,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ChatViewId } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -17,7 +18,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; @@ -233,6 +234,46 @@ async function addHookToFile( } } +/** + * Awaits a single pick interaction on the given picker. + * Returns the selected item, 'back' if the back button was pressed, or undefined if cancelled. + */ +function awaitPick( + picker: IQuickPick, + backButton: IQuickInputButton, +): Promise { + return new Promise(resolve => { + let resolved = false; + const done = (value: T | 'back' | undefined) => { + if (!resolved) { + resolved = true; + disposables.dispose(); + resolve(value); + } + }; + const disposables = new DisposableStore(); + disposables.add(picker.onDidAccept(() => { + done(picker.activeItems[0] as T | undefined); + })); + disposables.add(picker.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + disposables.add(picker.onDidHide(() => { + done(undefined); + })); + }); +} + +const enum Step { + SelectHookType = 1, + SelectHook = 2, + SelectFile = 3, + SelectFolder = 4, + EnterFilename = 5, +} + /** * Shows the Configure Hooks quick pick UI, allowing the user to view, * open, or create hooks. Can be called from the action or slash command. @@ -278,258 +319,384 @@ export async function showConfigureHooksQuickPick( hookCountByType.set(entry.hookType, (hookCountByType.get(entry.hookType) ?? 0) + 1); } - // Step 1: Show all lifecycle events with hook counts - const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { - const count = hookCountByType.get(hookType.id) ?? 0; - const countLabel = count > 0 ? ` (${count})` : ''; - return { - label: `${hookType.label}${countLabel}`, - description: hookType.description, - hookType - }; - }); - - const selectedHookType = await quickInputService.pick(hookTypeItems, { - placeHolder: localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'), - title: localize('commands.hooks.title', 'Hooks') - }); - - if (!selectedHookType) { - return; - } - - // Filter hooks by the selected type - const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType.hookType.id); - - // Step 2: Show "Add new hook" + existing hooks of this type - const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "Add new hook" option at the top - hookItems.push({ - label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, - isAddNewHook: true, - alwaysShow: true - }); - - // Add existing hooks - if (hooksOfType.length > 0) { - hookItems.push({ - type: 'separator', - label: localize('existingHooks', "Existing Hooks") - }); - - for (const entry of hooksOfType) { - const description = labelService.getUriLabel(entry.fileUri, { relative: true }); - hookItems.push({ - label: entry.commandLabel, - description, - hookEntry: entry - }); + // Create a single picker instance reused across all steps + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick({ useSeparators: true })); + const backButton = quickInputService.backButton; + let suppressHideDispose = false; + store.add(picker.onDidHide(() => { + if (!suppressHideDispose) { + store.dispose(); } - } + })); + picker.show(); - // Auto-execute if only "Add new hook" is available (no existing hooks) + let step = Step.SelectHookType; + let selectedHookType: IHookTypeQuickPickItem | undefined; let selectedHook: IHookQuickPickItem | undefined; - if (hooksOfType.length === 0) { - selectedHook = hookItems[0] as IHookQuickPickItem; - } else { - selectedHook = await quickInputService.pick(hookItems, { - placeHolder: localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'), - title: selectedHookType.hookType.label - }); - } + let selectedFile: IHookFileQuickPickItem | undefined; + let selectedFolder: { uri: URI } | undefined; - if (!selectedHook) { - return; - } + // Track steps that were actually shown to the user, so Back + // skips over auto-executed steps and returns to the last visible one. + const stepHistory: Step[] = []; + const goBack = (): Step | undefined => stepHistory.pop(); - // Handle clicking on existing hook (focus into command) - if (selectedHook.hookEntry) { - const entry = selectedHook.hookEntry; - let selection: ITextEditorSelection | undefined; - - // Determine the command field name to highlight based on target platform - const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); - - // Try to find the command field to highlight - if (commandFieldName) { - try { - const content = await fileService.readFile(entry.fileUri); - selection = findHookCommandSelection( - content.value.toString(), - entry.originalHookTypeId, - entry.index, - commandFieldName - ); - } catch { - // Ignore errors and just open without selection - } - } - - await editorService.openEditor({ - resource: entry.fileUri, - options: { - selection, - pinned: false - } - }); - return; - } - - // Step 3: Handle "Add new hook" - show create new file + existing hook files - if (selectedHook.isAddNewHook) { - // Get existing hook files (local storage only, not User Data) - const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); - - const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "Create new hook config file" option at the top - fileItems.push({ - label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, - isCreateNewFile: true, - alwaysShow: true - }); - - // Add existing hook files - if (hookFiles.length > 0) { - fileItems.push({ - type: 'separator', - label: localize('existingHookFiles', "Existing Hook Files") - }); - - for (const hookFile of hookFiles) { - const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); - fileItems.push({ - label: relativePath, - fileUri: hookFile.uri + while (true) { + switch (step) { + case Step.SelectHookType: { + // Step 1: Show all lifecycle events with hook counts + const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { + const count = hookCountByType.get(hookType.id) ?? 0; + const countLabel = count > 0 ? ` (${count})` : ''; + return { + label: `${hookType.label}${countLabel}`, + description: hookType.description, + hookType + }; }); - } - } - // Auto-execute if no existing hook files - let selectedFile: IHookFileQuickPickItem | undefined; - if (hookFiles.length === 0) { - selectedFile = fileItems[0] as IHookFileQuickPickItem; - } else { - selectedFile = await quickInputService.pick(fileItems, { - placeHolder: localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'), - title: localize('commands.hooks.addHook.title', 'Add Hook') - }); - } + picker.items = hookTypeItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'); + picker.title = localize('commands.hooks.title', 'Hooks'); + picker.buttons = []; - if (!selectedFile) { - return; - } + const result = await awaitPick(picker, backButton); - // Handle creating new hook config file - if (selectedFile.isCreateNewFile) { - // Get source folders for hooks, filter to local storage only (no User Data) - const allFolders = await promptsService.getSourceFolders(PromptsType.hook); - const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); - - if (localFolders.length === 0) { - notificationService.error(localize('commands.hook.noLocalFolders', "No local hook folder found. Please configure a hooks folder in your workspace.")); - return; - } - - // Auto-select if only one folder, otherwise show picker - let selectedFolder = localFolders[0]; - if (localFolders.length > 1) { - const folderItems = localFolders.map(folder => ({ - label: labelService.getUriLabel(folder.uri, { relative: true }), - folder - })); - const pickedFolder = await quickInputService.pick(folderItems, { - placeHolder: localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'), - title: localize('commands.hook.selectFolder.title', 'Hook File Location') - }); - if (!pickedFolder) { + if (!result || result === 'back') { + picker.hide(); return; } - selectedFolder = pickedFolder.folder; + + selectedHookType = result; + stepHistory.push(Step.SelectHookType); + step = Step.SelectHook; + break; } - // Ask for filename - const fileName = await quickInputService.input({ - prompt: localize('commands.hook.filename.prompt', "Enter hook file name"), - placeHolder: localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"), - validateInput: async (value) => { - if (!value || !value.trim()) { - return localize('commands.hook.filename.required', "File name is required"); + case Step.SelectHook: { + // Filter hooks by the selected type + const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType.id); + + // Step 2: Show "Add new hook" + existing hooks of this type + const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Add new hook" option at the top + hookItems.push({ + label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, + isAddNewHook: true, + alwaysShow: true + }); + + // Add existing hooks + if (hooksOfType.length > 0) { + hookItems.push({ + type: 'separator', + label: localize('existingHooks', "Existing Hooks") + }); + + for (const entry of hooksOfType) { + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); + hookItems.push({ + label: entry.commandLabel, + description, + hookEntry: entry + }); } - const name = value.trim(); - // Basic validation - no path separators or invalid characters - if (/[/\\:*?"<>|]/.test(name)) { - return localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); - } - return undefined; } - }); - if (!fileName) { - return; - } + // Auto-execute if only "Add new hook" is available (no existing hooks) + if (hooksOfType.length === 0) { + selectedHook = hookItems[0] as IHookQuickPickItem; + } else { + picker.items = hookItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'); + picker.title = selectedHookType!.hookType.label; + picker.buttons = [backButton]; - // Create the hooks folder if it doesn't exist - await fileService.createFolder(selectedFolder.uri); + const result = await awaitPick(picker, backButton); - // Use user-provided filename with .json extension - const hookFileName = fileName.trim().endsWith('.json') ? fileName.trim() : `${fileName.trim()}.json`; - const hookFileUri = URI.joinPath(selectedFolder.uri, hookFileName); + if (result === 'back') { + step = goBack() ?? Step.SelectHookType; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedHook = result; + stepHistory.push(Step.SelectHook); + } - // Check if file already exists - if (await fileService.exists(hookFileUri)) { - // File exists - add hook to it instead of creating new - await addHookToFile( - hookFileUri, - selectedHookType.hookType.id as HookType, - fileService, - editorService, - notificationService, - bulkEditService - ); - return; - } + // Handle clicking on existing hook (focus into command) + if (selectedHook.hookEntry) { + const entry = selectedHook.hookEntry; + let selection: ITextEditorSelection | undefined; - // Create new hook file with the selected hook type - const hooksContent = { - hooks: { - [selectedHookType.hookType.id]: [ - { - type: 'command', - command: '' + // Determine the command field name to highlight based on target platform + const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); + + // Try to find the command field to highlight + if (commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + commandFieldName + ); + } catch { + // Ignore errors and just open without selection } - ] + } + + picker.hide(); + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + return; } - }; - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + // "Add new hook" was selected + step = Step.SelectFile; + break; + } - // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, selectedHookType.hookType.id, 0, 'command'); + case Step.SelectFile: { + // Step 3: Handle "Add new hook" - show create new file + existing hook files + // Get existing hook files (local storage only, not User Data) + const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); - // Open editor with selection - await editorService.openEditor({ - resource: hookFileUri, - options: { - selection, - pinned: false + const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Create new hook config file" option at the top + fileItems.push({ + label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, + isCreateNewFile: true, + alwaysShow: true + }); + + // Add existing hook files + if (hookFiles.length > 0) { + fileItems.push({ + type: 'separator', + label: localize('existingHookFiles', "Existing Hook Files") + }); + + for (const hookFile of hookFiles) { + const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); + fileItems.push({ + label: relativePath, + fileUri: hookFile.uri + }); + } } - }); - return; - } - // Handle adding hook to existing file - if (selectedFile.fileUri) { - await addHookToFile( - selectedFile.fileUri, - selectedHookType.hookType.id as HookType, - fileService, - editorService, - notificationService, - bulkEditService - ); + // Auto-execute if no existing hook files + if (hookFiles.length === 0) { + selectedFile = fileItems[0] as IHookFileQuickPickItem; + } else { + picker.items = fileItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'); + picker.title = localize('commands.hooks.addHook.title', 'Add Hook'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectHook; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedFile = result; + stepHistory.push(Step.SelectFile); + } + + // Handle adding hook to existing file + if (selectedFile.fileUri) { + picker.hide(); + await addHookToFile( + selectedFile.fileUri, + selectedHookType!.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // "Create new hook config file" was selected + step = Step.SelectFolder; + break; + } + + case Step.SelectFolder: { + // Get source folders for hooks + const allFolders = await promptsService.getSourceFolders(PromptsType.hook); + const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); + + if (localFolders.length === 0) { + picker.hide(); + notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); + return; + } + + // Auto-select if only one folder, otherwise show picker + selectedFolder = localFolders[0]; + if (localFolders.length > 1) { + const folderItems = localFolders.map(folder => ({ + label: labelService.getUriLabel(folder.uri, { relative: true }), + folder + })); + + picker.items = folderItems; + picker.value = ''; + picker.placeholder = localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'); + picker.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectFile; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedFolder = result.folder; + stepHistory.push(Step.SelectFolder); + } + + step = Step.EnterFilename; + break; + } + + case Step.EnterFilename: { + // Hide the picker and show an input box for the filename + suppressHideDispose = true; + picker.hide(); + suppressHideDispose = false; + + const fileNameResult = await new Promise(resolve => { + let resolved = false; + const done = (value: string | 'back' | undefined) => { + if (!resolved) { + resolved = true; + inputDisposables.dispose(); + resolve(value); + } + }; + const inputDisposables = new DisposableStore(); + const inputBox = inputDisposables.add(quickInputService.createInputBox()); + inputBox.prompt = localize('commands.hook.filename.prompt', "Enter hook file name"); + inputBox.placeholder = localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"); + inputBox.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + inputBox.buttons = [backButton]; + inputBox.ignoreFocusOut = true; + + inputDisposables.add(inputBox.onDidAccept(async () => { + const value = inputBox.value; + if (!value || !value.trim()) { + inputBox.validationMessage = localize('commands.hook.filename.required', "File name is required"); + return; + } + const name = value.trim(); + if (/[/\\:*?"<>|]/.test(name)) { + inputBox.validationMessage = localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); + return; + } + done(name); + })); + inputDisposables.add(inputBox.onDidChangeValue(() => { + inputBox.validationMessage = undefined; + })); + inputDisposables.add(inputBox.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + inputDisposables.add(inputBox.onDidHide(() => { + done(undefined); + })); + inputBox.show(); + }); + + if (fileNameResult === 'back') { + // Re-show the picker for the previous step + picker.show(); + step = goBack() ?? Step.SelectFolder; + break; + } + if (!fileNameResult) { + store.dispose(); + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder!.uri); + + // Use user-provided filename with .json extension + const hookFileName = fileNameResult.endsWith('.json') ? fileNameResult : `${fileNameResult}.json`; + const hookFileUri = URI.joinPath(selectedFolder!.uri, hookFileName); + + // Check if file already exists + if (await fileService.exists(hookFileUri)) { + // File exists - add hook to it instead of creating new + store.dispose(); + await addHookToFile( + hookFileUri, + selectedHookType!.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // Create new hook file with the selected hook type + const hooksContent = { + hooks: { + [selectedHookType!.hookType.id]: [ + { + type: 'command', + command: '' + } + ] + } + }; + + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, selectedHookType!.hookType.id, 0, 'command'); + + // Open editor with selection + store.dispose(); + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + return; + } } } } 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 034145f82f7..c8196999288 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -427,8 +427,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - if (type !== PromptsType.skill) { - // no user source folders for skills + if (type !== PromptsType.skill && type !== PromptsType.hook) { + // no user source folders for skills and hooks const userHome = this.userDataService.currentProfile.promptsHome; result.push({ uri: userHome, storage: PromptsStorage.user, type }); } From 8def2f99b7a49a24159ed1ebf7e11f01c2997e0b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:47:20 -0800 Subject: [PATCH 21/64] @xterm/xterm@6.1.0-beta.152 Fixes #293529 --- package-lock.json | 96 ++++++++++++++++++------------------ package.json | 20 ++++---- remote/package-lock.json | 96 ++++++++++++++++++------------------ remote/package.json | 20 ++++---- remote/web/package-lock.json | 88 ++++++++++++++++----------------- remote/web/package.json | 18 +++---- 6 files changed, 169 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49beda3da1a..7c6806f2b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,16 +30,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -3895,30 +3895,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3928,7 +3928,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3950,63 +3950,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index d7794f4e979..e79cfc22656 100644 --- a/package.json +++ b/package.json @@ -92,16 +92,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index a6c0504563b..de6d200c162 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -577,30 +577,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -610,67 +610,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index e90da38f129..603dc5559ba 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index a5d7be69eff..e83d2319329 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -99,30 +99,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -132,58 +132,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 4bf2586bc81..f738d5554fa 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", From 0dd79e6eb9471b016a53be10fac2a77a2db4d292 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:32:36 -0800 Subject: [PATCH 22/64] Adopt new scrollbar API --- .../contrib/terminal/browser/xterm/xtermTerminal.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 96098673241..d66f1c39a38 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -244,9 +244,11 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, - overviewRuler: options.disableOverviewRuler ? { width: 0 } : { + scrollbar: options.disableOverviewRuler ? undefined : { width: 14, - showTopBorder: true, + overviewRuler: { + showTopBorder: true, + }, }, ignoreBracketedPasteMode: config.ignoreBracketedPasteMode, rescaleOverlappingGlyphs: config.rescaleOverlappingGlyphs, From 6373cdaa8315c01b00610d465ae65be8457c5673 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 10:43:44 +0000 Subject: [PATCH 23/64] Reduce dimensions of activity bar components for improved UI consistency --- .../parts/activitybar/activitybarPart.ts | 6 +-- .../activitybar/media/activityaction.css | 38 +++++++++---------- .../activitybar/media/activitybarpart.css | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 080b6871061..35069d1fa96 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -41,7 +41,7 @@ import { SwitchCompositeViewAction } from '../compositeBarActions.js'; export class ActivitybarPart extends Part { - static readonly ACTION_HEIGHT = 48; + static readonly ACTION_HEIGHT = 32; static readonly pinnedViewContainersKey = 'workbench.activity.pinnedViewlets2'; static readonly placeholderViewContainersKey = 'workbench.activity.placeholderViewlets'; @@ -49,8 +49,8 @@ export class ActivitybarPart extends Part { //#region IView - readonly minimumWidth: number = 48; - readonly maximumWidth: number = 48; + readonly minimumWidth: number = 40; + readonly maximumWidth: number = 40; readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 8cc8ca0e484..9186e5c7697 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -12,7 +12,7 @@ .monaco-workbench .activitybar > .content .composite-bar > .monaco-action-bar .action-item::after { position: absolute; content: ''; - width: 48px; + width: 40px; height: 2px; display: none; background-color: transparent; @@ -46,8 +46,8 @@ z-index: 1; display: flex; overflow: hidden; - width: 48px; - height: 48px; + width: 40px; + height: 32px; margin-right: 0; box-sizing: border-box; @@ -55,12 +55,12 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label:not(.codicon) { font-size: 15px; - line-height: 40px; - padding: 0 0 0 48px; + line-height: 32px; + padding: 0 0 0 40px; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label.codicon { - font-size: 24px; + font-size: 16px; align-items: center; justify-content: center; } @@ -157,27 +157,27 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { position: absolute; - top: 24px; - right: 8px; + top: 16px; + right: 7px; font-size: 9px; font-weight: 600; - min-width: 8px; - height: 16px; - line-height: 16px; - padding: 0 4px; - border-radius: 20px; + min-width: 9px; + height: 13px; + line-height: 13px; + padding: 0 2px; + border-radius: 13px; text-align: center; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { position: absolute; font-weight: 600; - font-size: 9px; - line-height: 10px; - top: 24px; - right: 6px; - padding: 2px 3px; - border-radius: 7px; + font-size: 8px; + line-height: 8px; + top: 14px; + right: 2px; + padding: 2px 2px; + border-radius: 6px; background-color: var(--vscode-profileBadge-background); color: var(--vscode-profileBadge-foreground); border: 2px solid var(--vscode-activityBar-background); diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index abe1427ca20..644f616aeb5 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-workbench .part.activitybar { - width: 48px; + width: 40px; height: 100%; } From e06393eaf862ca4ff83226eebc320227d10ec60d Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 10:49:02 +0000 Subject: [PATCH 24/64] Reduce activity bar dimensions for improved UI consistency --- .../browser/parts/activitybar/activitybarPart.ts | 4 ++-- .../browser/parts/activitybar/media/activityaction.css | 10 +++++----- .../parts/activitybar/media/activitybarpart.css | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 35069d1fa96..6978ffacbf0 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -49,8 +49,8 @@ export class ActivitybarPart extends Part { //#region IView - readonly minimumWidth: number = 40; - readonly maximumWidth: number = 40; + readonly minimumWidth: number = 36; + readonly maximumWidth: number = 36; readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 9186e5c7697..0027174d78e 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -12,7 +12,7 @@ .monaco-workbench .activitybar > .content .composite-bar > .monaco-action-bar .action-item::after { position: absolute; content: ''; - width: 40px; + width: 36px; height: 2px; display: none; background-color: transparent; @@ -46,7 +46,7 @@ z-index: 1; display: flex; overflow: hidden; - width: 40px; + width: 36px; height: 32px; margin-right: 0; box-sizing: border-box; @@ -56,7 +56,7 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label:not(.codicon) { font-size: 15px; line-height: 32px; - padding: 0 0 0 40px; + padding: 0 0 0 36px; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label.codicon { @@ -157,8 +157,8 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { position: absolute; - top: 16px; - right: 7px; + top: 18px; + right: 6px; font-size: 9px; font-weight: 600; min-width: 9px; diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index 644f616aeb5..452cc971b24 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-workbench .part.activitybar { - width: 40px; + width: 36px; height: 100%; } From 21f33aa38050814521e19ed57dfe933517bf05ad Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 10:54:58 +0000 Subject: [PATCH 25/64] Adjust badge positioning and remove border for improved styling --- .../browser/parts/activitybar/media/activityaction.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 0027174d78e..6c19b80055b 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -157,7 +157,7 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { position: absolute; - top: 18px; + top: 17px; right: 6px; font-size: 9px; font-weight: 600; @@ -167,6 +167,7 @@ padding: 0 2px; border-radius: 13px; text-align: center; + border: none !important; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { From ebcfe64355fb9e9e2179598b92217a025c0c8b98 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Tue, 10 Feb 2026 12:33:08 +0100 Subject: [PATCH 26/64] using padding so editor hover is not visible at the same time as selection is made (#294091) --- .../editor/contrib/hover/browser/hoverUtils.ts | 12 ++++++++---- .../hover/test/browser/hoverUtils.test.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/vs/editor/contrib/hover/browser/hoverUtils.ts b/src/vs/editor/contrib/hover/browser/hoverUtils.ts index 997d4512c1a..1dc56a043b6 100644 --- a/src/vs/editor/contrib/hover/browser/hoverUtils.ts +++ b/src/vs/editor/contrib/hover/browser/hoverUtils.ts @@ -6,12 +6,16 @@ import * as dom from '../../../../base/browser/dom.js'; import { IEditorMouseEvent } from '../../../browser/editorBrowser.js'; +const enum PADDING { + VALUE = 3 +} + export function isMousePositionWithinElement(element: HTMLElement, posx: number, posy: number): boolean { const elementRect = dom.getDomNodePagePosition(element); - if (posx < elementRect.left - || posx > elementRect.left + elementRect.width - || posy < elementRect.top - || posy > elementRect.top + elementRect.height) { + if (posx < elementRect.left + PADDING.VALUE + || posx > elementRect.left + elementRect.width - PADDING.VALUE + || posy < elementRect.top + PADDING.VALUE + || posy > elementRect.top + elementRect.height - PADDING.VALUE) { return false; } return true; diff --git a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts index e40987aeefe..593eb58d304 100644 --- a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts +++ b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts @@ -114,10 +114,10 @@ suite('Hover Utils', () => { test('returns true when mouse is on element edges', () => { const element = createMockElement(100, 100, 200, 100); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); // top-left corner - assert.strictEqual(isMousePositionWithinElement(element, 300, 100), true); // top-right corner - assert.strictEqual(isMousePositionWithinElement(element, 100, 200), true); // bottom-left corner - assert.strictEqual(isMousePositionWithinElement(element, 300, 200), true); // bottom-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); // top-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 100), false); // top-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 200), false); // bottom-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 200), false); // bottom-right corner }); test('returns false when mouse is left of element', () => { @@ -146,16 +146,16 @@ suite('Hover Utils', () => { test('handles element at origin (0,0)', () => { const element = createMockElement(0, 0, 100, 100); - assert.strictEqual(isMousePositionWithinElement(element, 0, 0), true); + assert.strictEqual(isMousePositionWithinElement(element, 0, 0), false); assert.strictEqual(isMousePositionWithinElement(element, 50, 50), true); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); }); test('handles small elements (1x1)', () => { const element = createMockElement(100, 100, 1, 1); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); - assert.strictEqual(isMousePositionWithinElement(element, 101, 101), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); assert.strictEqual(isMousePositionWithinElement(element, 102, 102), false); }); }); From 64894bd732ead3bb112836d995ddf01a45e5f445 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:33:23 -0800 Subject: [PATCH 27/64] Revert "Merge pull request #294081 from microsoft/tyriar/xterm_20260210" This reverts commit fbefd6388bb05a16744a2b1271a0b691271a7617, reversing changes made to f8b1fe06c5bfb780ca5a1f96046e202359bec33c. --- package-lock.json | 96 +++++++++---------- package.json | 20 ++-- remote/package-lock.json | 96 +++++++++---------- remote/package.json | 20 ++-- remote/web/package-lock.json | 88 ++++++++--------- remote/web/package.json | 18 ++-- .../terminal/browser/xterm/xtermTerminal.ts | 6 +- 7 files changed, 171 insertions(+), 173 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c6806f2b4d..49beda3da1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,16 +30,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.144", + "@xterm/addon-image": "^0.10.0-beta.144", + "@xterm/addon-ligatures": "^0.11.0-beta.144", + "@xterm/addon-progress": "^0.3.0-beta.144", + "@xterm/addon-search": "^0.17.0-beta.144", + "@xterm/addon-serialize": "^0.15.0-beta.144", + "@xterm/addon-unicode11": "^0.10.0-beta.144", + "@xterm/addon-webgl": "^0.20.0-beta.143", + "@xterm/headless": "^6.1.0-beta.144", + "@xterm/xterm": "^6.1.0-beta.144", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -3895,30 +3895,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", - "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", + "version": "0.3.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", + "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", - "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", + "version": "0.10.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", + "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", - "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", + "version": "0.11.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", + "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3928,7 +3928,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3950,63 +3950,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", - "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", + "version": "0.3.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", + "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", - "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", + "version": "0.17.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", + "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", - "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", + "version": "0.15.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", + "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", - "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", + "version": "0.10.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", + "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.151", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", - "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", + "version": "0.20.0-beta.143", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", + "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", - "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", + "version": "6.1.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", + "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", - "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", + "version": "6.1.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", + "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index e79cfc22656..d7794f4e979 100644 --- a/package.json +++ b/package.json @@ -92,16 +92,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.144", + "@xterm/addon-image": "^0.10.0-beta.144", + "@xterm/addon-ligatures": "^0.11.0-beta.144", + "@xterm/addon-progress": "^0.3.0-beta.144", + "@xterm/addon-search": "^0.17.0-beta.144", + "@xterm/addon-serialize": "^0.15.0-beta.144", + "@xterm/addon-unicode11": "^0.10.0-beta.144", + "@xterm/addon-webgl": "^0.20.0-beta.143", + "@xterm/headless": "^6.1.0-beta.144", + "@xterm/xterm": "^6.1.0-beta.144", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index de6d200c162..a6c0504563b 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.144", + "@xterm/addon-image": "^0.10.0-beta.144", + "@xterm/addon-ligatures": "^0.11.0-beta.144", + "@xterm/addon-progress": "^0.3.0-beta.144", + "@xterm/addon-search": "^0.17.0-beta.144", + "@xterm/addon-serialize": "^0.15.0-beta.144", + "@xterm/addon-unicode11": "^0.10.0-beta.144", + "@xterm/addon-webgl": "^0.20.0-beta.143", + "@xterm/headless": "^6.1.0-beta.144", + "@xterm/xterm": "^6.1.0-beta.144", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -577,30 +577,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", - "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", + "version": "0.3.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", + "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", - "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", + "version": "0.10.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", + "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", - "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", + "version": "0.11.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", + "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -610,67 +610,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", - "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", + "version": "0.3.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", + "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", - "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", + "version": "0.17.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", + "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", - "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", + "version": "0.15.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", + "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", - "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", + "version": "0.10.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", + "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.151", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", - "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", + "version": "0.20.0-beta.143", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", + "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", - "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", + "version": "6.1.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", + "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", - "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", + "version": "6.1.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", + "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index 603dc5559ba..e90da38f129 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.144", + "@xterm/addon-image": "^0.10.0-beta.144", + "@xterm/addon-ligatures": "^0.11.0-beta.144", + "@xterm/addon-progress": "^0.3.0-beta.144", + "@xterm/addon-search": "^0.17.0-beta.144", + "@xterm/addon-serialize": "^0.15.0-beta.144", + "@xterm/addon-unicode11": "^0.10.0-beta.144", + "@xterm/addon-webgl": "^0.20.0-beta.143", + "@xterm/headless": "^6.1.0-beta.144", + "@xterm/xterm": "^6.1.0-beta.144", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index e83d2319329..a5d7be69eff 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.144", + "@xterm/addon-image": "^0.10.0-beta.144", + "@xterm/addon-ligatures": "^0.11.0-beta.144", + "@xterm/addon-progress": "^0.3.0-beta.144", + "@xterm/addon-search": "^0.17.0-beta.144", + "@xterm/addon-serialize": "^0.15.0-beta.144", + "@xterm/addon-unicode11": "^0.10.0-beta.144", + "@xterm/addon-webgl": "^0.20.0-beta.143", + "@xterm/xterm": "^6.1.0-beta.144", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -99,30 +99,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", - "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", + "version": "0.3.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", + "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", - "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", + "version": "0.10.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", + "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", - "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", + "version": "0.11.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", + "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -132,58 +132,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", - "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", + "version": "0.3.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", + "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", - "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", + "version": "0.17.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", + "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", - "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", + "version": "0.15.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", + "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", - "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", + "version": "0.10.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", + "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.151", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", - "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", + "version": "0.20.0-beta.143", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", + "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.144" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", - "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", + "version": "6.1.0-beta.144", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", + "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index f738d5554fa..4bf2586bc81 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.144", + "@xterm/addon-image": "^0.10.0-beta.144", + "@xterm/addon-ligatures": "^0.11.0-beta.144", + "@xterm/addon-progress": "^0.3.0-beta.144", + "@xterm/addon-search": "^0.17.0-beta.144", + "@xterm/addon-serialize": "^0.15.0-beta.144", + "@xterm/addon-unicode11": "^0.10.0-beta.144", + "@xterm/addon-webgl": "^0.20.0-beta.143", + "@xterm/xterm": "^6.1.0-beta.144", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index d66f1c39a38..96098673241 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -244,11 +244,9 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, - scrollbar: options.disableOverviewRuler ? undefined : { + overviewRuler: options.disableOverviewRuler ? { width: 0 } : { width: 14, - overviewRuler: { - showTopBorder: true, - }, + showTopBorder: true, }, ignoreBracketedPasteMode: config.ignoreBracketedPasteMode, rescaleOverlappingGlyphs: config.rescaleOverlappingGlyphs, From 7e883bb39849880de7e865720ba34e649cb6a0ca Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 10 Feb 2026 03:42:44 -0800 Subject: [PATCH 28/64] Preserve update metadata over application and OS restarts (#294087) --- .../electron-main/updateService.win32.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 37c7f4e8ec1..c0248864079 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -245,6 +245,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); }).then(packagePath => { this.availableUpdate = { packagePath }; + this.saveUpdateMetadata(update); this.setState(State.Downloaded(update, explicit, this._overwrite)); const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); @@ -352,6 +353,30 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private async saveUpdateMetadata(update: IUpdate): Promise { + try { + const cachePath = await this.cachePath; + const metadataPath = path.join(cachePath, 'update-metadata.json'); + await pfs.Promises.writeFile(metadataPath, JSON.stringify(update)); + } catch (e) { + this.logService.error('update#saveUpdateMetadata: failed to save', e); + } + } + + private async loadUpdateMetadata(): Promise { + try { + const cachePath = await this.cachePath; + const metadataPath = path.join(cachePath, 'update-metadata.json'); + if (await pfs.Promises.exists(metadataPath)) { + const content = await readFile(metadataPath, 'utf8'); + return JSON.parse(content); + } + } catch (e) { + this.logService.error('update#loadUpdateMetadata: failed to load', e); + } + return undefined; + } + protected override getUpdateType(): UpdateType { return getUpdateType(); } @@ -362,7 +387,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - const update: IUpdate = { version: 'unknown', productVersion: 'unknown' }; + const update: IUpdate = await this.loadUpdateMetadata() ?? { version: 'unknown', productVersion: 'unknown' }; this.setState(State.Downloading(update, true, false)); this.availableUpdate = { packagePath }; From e60eb8c3b11db7698733256ba4cce3d096d3ddd8 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 10 Feb 2026 04:32:55 -0800 Subject: [PATCH 29/64] Fix double update issue on Windows (#292746) --- build/win32/code.iss | 16 ++- .../electron-main/abstractUpdateService.ts | 10 +- .../electron-main/updateService.win32.ts | 133 +++++++++++++++--- 3 files changed, 136 insertions(+), 23 deletions(-) diff --git a/build/win32/code.iss b/build/win32/code.iss index d889ca8c4d3..0d47e15103f 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1453,6 +1453,12 @@ begin Result := not (IsBackgroundUpdate() and FileExists(Path)); end; +// Check if VS Code created a cancel file to signal that the update should be aborted +function CancelFileExists(): Boolean; +begin + Result := FileExists(ExpandConstant('{param:cancel}')) +end; + function ShouldRunAfterUpdate(): Boolean; begin if IsBackgroundUpdate() then @@ -1639,11 +1645,17 @@ begin Log('Checking whether application is still running...'); while (CheckForMutexes('{#AppMutex}')) do begin + if CancelFileExists() then + begin + Log('Cancel file detected, aborting background update.'); + DeleteFile(ExpandConstant('{app}\updating_version')); + Abort; + end; Sleep(1000) end; Log('Application appears not to be running.'); - if not SessionEndFileExists() then begin + if not SessionEndFileExists() and not CancelFileExists() then begin StopTunnelServiceIfNeeded(); Log('Invoking inno_updater for background update'); Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); @@ -1657,7 +1669,7 @@ begin end; #endif end else begin - Log('Skipping inno_updater.exe call because OS session is ending'); + Log('Skipping inno_updater.exe call because OS session is ending or cancel was requested'); end; end else begin if IsVersionedUpdate() then begin diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 0c396b41681..ed54d90f383 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -253,7 +253,15 @@ export abstract class AbstractUpdateService implements IUpdateService { if (isLatest === false && this._state.type === StateType.Ready) { this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); - await this.cancelPendingUpdate(); + + try { + await this.cancelPendingUpdate(); + } catch (error) { + this.logService.error('update#checkForOverwriteUpdates(): failed to cancel pending update, aborting overwrite'); + this.logService.error(error); + return false; + } + this._overwrite = true; this.setState(State.Overwriting(this._state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index c0248864079..7778a01ffa3 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; @@ -18,6 +18,7 @@ import { transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { checksum } from '../../../base/node/crypto.js'; import * as pfs from '../../../base/node/pfs.js'; +import { killTree } from '../../../base/node/processes.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { IFileService } from '../../files/common/files.js'; @@ -40,6 +41,10 @@ async function pollUntil(fn: () => boolean, millis = 1000): Promise { interface IAvailableUpdate { packagePath: string; updateFilePath?: string; + /** File path used to signal the Inno Setup installer to cancel */ + cancelFilePath?: string; + /** The Inno Setup process that is applying the update in the background */ + updateProcess?: ChildProcess; } let _updateType: UpdateType | undefined = undefined; @@ -75,7 +80,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun @IProductService productService: IProductService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -168,14 +173,18 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return createUpdateURL(this.productService.updateUrl!, platform, quality, commit, options); } - protected doCheckForUpdates(explicit: boolean): void { + protected doCheckForUpdates(explicit: boolean, pendingCommit?: string): void { if (!this.quality) { return; } const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background }); - this.setState(State.CheckingForUpdates(explicit)); + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + + // Only set CheckingForUpdates if we're not already in Overwriting state + if (this.state.type !== StateType.Overwriting) { + this.setState(State.CheckingForUpdates(explicit)); + } this.requestService.request({ url }, CancellationToken.None) .then(asJson) @@ -183,7 +192,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const updateType = getUpdateType(); if (!update || !update.url || !update.version || !update.productVersion) { - this.setState(State.Idle(updateType)); + // If we were checking for an overwrite update and found nothing newer, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(updateType)); + } return Promise.resolve(null); } @@ -249,10 +265,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.Downloaded(update, explicit, this._overwrite)); const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, explicit, this._overwrite)); } @@ -265,7 +279,15 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // only show message when explicitly checking for updates const message: string | undefined = explicit ? (err.message || err) : undefined; - this.setState(State.Idle(getUpdateType(), message)); + + // If we were checking for an overwrite update and it failed, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(getUpdateType(), message)); + } }); } @@ -313,15 +335,36 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const cachePath = await this.cachePath; const sessionEndFlagPath = path.join(cachePath, 'session-ending.flag'); + const cancelFilePath = path.join(cachePath, `cancel.flag`); + try { + await unlink(cancelFilePath); + } catch { + // ignore + } this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); + this.availableUpdate.cancelFilePath = cancelFilePath; await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); - const child = spawn(this.availableUpdate.packagePath, ['/verysilent', '/log', `/update="${this.availableUpdate.updateFilePath}"`, `/sessionend="${sessionEndFlagPath}"`, '/nocloseapplications', '/mergetasks=runcode,!desktopicon,!quicklaunchicon'], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true - }); + const child = spawn(this.availableUpdate.packagePath, + [ + '/verysilent', + '/log', + `/update="${this.availableUpdate.updateFilePath}"`, + `/sessionend="${sessionEndFlagPath}"`, + `/cancel="${cancelFilePath}"`, + '/nocloseapplications', + '/mergetasks=runcode,!desktopicon,!quicklaunchicon' + ], + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + windowsVerbatimArguments: true + } + ); + + // Track the process so we can cancel it if needed + this.availableUpdate.updateProcess = child; child.once('exit', () => { this.availableUpdate = undefined; @@ -336,6 +379,58 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => this.setState(State.Ready(update, explicit, this._overwrite))); } + protected override async cancelPendingUpdate(): Promise { + if (!this.availableUpdate) { + return; + } + + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; + + if (updateProcess && updateProcess.exitCode === null) { + // Remove all listeners to prevent the exit handler from changing state + updateProcess.removeAllListeners(); + const exitPromise = new Promise(resolve => updateProcess.once('exit', () => resolve(true))); + + // Write the cancel file to signal Inno Setup to exit gracefully + if (cancelFilePath) { + try { + await pfs.Promises.writeFile(cancelFilePath, 'cancel'); + } catch (err) { + this.logService.warn('update#cancelPendingUpdate: failed to write cancel file', err); + } + } + + // Wait for the process to exit gracefully, then force-kill if needed + const pid = updateProcess.pid; + const exited = await Promise.race([exitPromise, timeout(30 * 1000).then(() => false)]); + if (pid && !exited) { + this.logService.trace('update#cancelPendingUpdate: process did not exit gracefully, killing process tree'); + await killTree(pid, true); + } + } + + // Clean up the flag file + if (updateFilePath) { + try { + await unlink(updateFilePath); + } catch (err) { + // ignore + } + } + + // Clean up the cancel file + if (cancelFilePath) { + try { + await unlink(cancelFilePath); + } catch (err) { + // ignore + } + } + + this.availableUpdate = undefined; + } + protected override doQuitAndInstall(): void { if (this.state.type !== StateType.Ready || !this.availableUpdate) { return; @@ -393,10 +488,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, true, false)); } From ed67fd6d5e5757306cb9b173ffe49c23d5a7a333 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Tue, 10 Feb 2026 16:02:25 +0100 Subject: [PATCH 30/64] using the addsmall and removesmall codicons (#294100) --- .../editor/contrib/hover/browser/markdownHoverParticipant.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts index e289a3dbca0..ba261eaa4a4 100644 --- a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts @@ -36,8 +36,8 @@ import { HoverStartSource } from './hoverOperation.js'; import { ScrollEvent } from '../../../../base/common/scrollable.js'; const $ = dom.$; -const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.add, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); -const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.remove, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); +const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.addSmall, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); +const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.removeSmall, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); export class MarkdownHover implements IHoverPart { From 02c85ea7ba33085261f97b44a5d5663fae4a8b7b Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:09:22 +0000 Subject: [PATCH 31/64] Enhance 2026 theme: update editor sticky scroll background and adjust light theme tab and breadcrumb colors --- extensions/theme-2026/themes/2026-dark.json | 1 + extensions/theme-2026/themes/2026-light.json | 4 +- extensions/theme-2026/themes/styles.css | 54 +++++++------------- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 40140f3b6ac..76e870eca8b 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -102,6 +102,7 @@ "commandCenter.border": "#2E3031", "editor.background": "#121314", "editor.foreground": "#BBBEBF", + "editorStickyScroll.background": "#121314", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index da3849b0028..59fcde38957 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -185,7 +185,7 @@ "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "tab.activeBackground": "#FAFAFD", + "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", "tab.inactiveForeground": "#666666", @@ -202,7 +202,7 @@ "editorGroupHeader.tabsBackground": "#FAFAFD", "editorGroupHeader.tabsBorder": "#F2F3F4FF", "breadcrumb.foreground": "#666666", - "breadcrumb.background": "#FAFAFD", + "breadcrumb.background": "#FFFFFF", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", "breadcrumbPicker.background": "#F0F0F3", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 0141a1a695c..18dd038f6f4 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -131,15 +131,11 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); + /* background: var(--vs) */ position: relative; z-index: 5; border-radius: 0; border-top: none !important; - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent) 0%, - transparent 100% - ), var(--vscode-tab-activeBackground) !important; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { @@ -437,6 +433,10 @@ background: color-mix(in srgb, var(--vscode-breadcrumbPicker-background) 60%, transparent) !important; } +.monaco-workbench.vs .breadcrumbs-control { + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + /* Input Boxes */ .monaco-workbench .monaco-inputbox, .monaco-workbench .suggest-input-container { @@ -569,10 +569,14 @@ /* Sticky Scroll */ .monaco-workbench .monaco-editor .sticky-widget { box-shadow: var(--shadow-md) !important; - border-bottom: none !important; - background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; + border-bottom: var(--vscode-editorWidget-border) !important; + background: transparent !important; + backdrop-filter: var(--backdrop-blur-md) !important; + -webkit-backdrop-filter: var(--backdrop-blur-md) !important; +} + +.monaco-workbench .monaco-editor .sticky-widget > * { + background: transparent !important; } .monaco-workbench.vs-dark .monaco-editor .sticky-widget { @@ -580,31 +584,9 @@ } .monaco-workbench .monaco-editor .sticky-widget .sticky-widget-lines { - background-color: transparent !important; - background: transparent !important; -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget, -.monaco-workbench .monaco-editor .sticky-widget-focus-preview, -.monaco-workbench .monaco-editor .sticky-scroll-focus-line, -.monaco-workbench .monaco-editor .focused .sticky-widget, -.monaco-workbench .monaco-editor:has(.sticky-widget:focus-within) .sticky-widget { - background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - box-shadow: var(--shadow-hover) !important; -} - -.monaco-editor .sticky-widget .sticky-line-content, -.monaco-workbench .monaco-editor .sticky-widget .sticky-line-number { - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-content, -.monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-number { - background-color: color-mix(in srgb, var(--vscode-editor-background) 30%, transparent); + background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; + backdrop-filter: var(--backdrop-blur-md) !important; + -webkit-backdrop-filter: var(--backdrop-blur-md) !important; } .monaco-editor .rename-box.preview { @@ -616,9 +598,9 @@ /* Notebook */ -/* .monaco-workbench .notebookOverlay.notebook-editor { +.monaco-workbench .notebookOverlay.notebook-editor { z-index: 35 !important; -} */ +} .monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { box-shadow: inset var(--shadow-sm); From 9b7047a5f04c8add7b8d83870419be86fadc7cfa Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:45:58 +0000 Subject: [PATCH 32/64] Update widget border colors and adjust border-radius for consistency in themes --- extensions/theme-2026/themes/2026-light.json | 8 ++--- extensions/theme-2026/themes/styles.css | 32 ++++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index da3849b0028..2d4524f4947 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -49,7 +49,7 @@ "inputValidation.errorForeground": "#202020", "scrollbar.shadow": "#00000000", "widget.shadow": "#00000000", - "widget.border": "#F2F3F4FF", + "widget.border": "#EEEEF1", "editorStickyScroll.shadow": "#00000000", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", @@ -133,15 +133,15 @@ "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F2F3F4FF", "editorWidget.background": "#F0F0F3", - "editorWidget.border": "#F2F3F4FF", + "editorWidget.border": "#EEEEF1", "editorWidget.foreground": "#202020", "editorSuggestWidget.background": "#F0F0F3", - "editorSuggestWidget.border": "#F2F3F4FF", + "editorSuggestWidget.border": "#EEEEF1", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", "editorHoverWidget.background": "#F0F0F3", - "editorHoverWidget.border": "#F2F3F4FF", + "editorHoverWidget.border": "#EEEEF1", "peekView.border": "#0069CC", "peekViewEditor.background": "#F0F0F3", "peekViewEditor.matchHighlightBackground": "#0069CC33", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 0141a1a695c..ba7b49f8c63 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -7,7 +7,7 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; - --radius-xl: 12px; + /* --radius-lg: 12px; */ --shadow-xs: 0 0 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); @@ -189,10 +189,6 @@ border: 1px solid var(--vscode-menu-border) !important; } -.monaco-workbench .quick-input-widget .monaco-list-rows { - background-color: transparent !important; -} - .monaco-workbench .quick-input-widget .quick-input-header, .monaco-workbench .quick-input-widget .quick-input-list, .monaco-workbench .quick-input-widget .quick-input-titlebar, @@ -207,6 +203,10 @@ outline: none !important; } +.monaco-workbench .monaco-editor .suggest-widget .monaco-list { + border-radius: var(--radius-lg); +} + .monaco-workbench .quick-input-widget .monaco-inputbox { box-shadow: none !important; background: transparent !important; @@ -286,7 +286,7 @@ /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { box-shadow: var(--shadow-lg); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); } @@ -294,7 +294,7 @@ .monaco-workbench .context-view .monaco-menu { box-shadow: var(--shadow-lg); border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } .monaco-workbench .action-widget { @@ -310,8 +310,7 @@ /* Suggest Widget */ .monaco-workbench .monaco-editor .suggest-widget { box-shadow: var(--shadow-lg); - border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); background: color-mix(in srgb, var(--vscode-editorSuggestWidget-background) 60%, transparent) !important; @@ -331,10 +330,17 @@ margin-top: 4px !important; } +.monaco-workbench .inline-chat-gutter-menu { + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: var(--backdrop-blur-md); + -webkit-backdrop-filter: var(--backdrop-blur-md); +} + /* Dialog */ .monaco-workbench .monaco-dialog-box { box-shadow: var(--shadow-2xl); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; @@ -538,7 +544,7 @@ /* Parameter Hints */ .monaco-workbench .monaco-editor .parameter-hints-widget { box-shadow: var(--shadow-lg); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); } @@ -641,7 +647,7 @@ .monaco-workbench .monaco-editor .inline-chat { box-shadow: var(--shadow-lg); border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } /* Command Center */ @@ -678,7 +684,7 @@ } .monaco-dialog-modal-block .dialog-shadow { - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } .monaco-workbench .unified-quick-access-tabs { From 95eaf36777b85502ddffce44f95d3c81e6195cd4 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 10 Feb 2026 16:52:59 +0100 Subject: [PATCH 33/64] Remove ask mode from picker and add a specific icon for it. --- .../widget/input/modePickerActionItem.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index ca2ba9b331c..071230853fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -45,7 +45,13 @@ export interface IModePickerDelegate { } // TODO: there should be an icon contributed for built-in modes -const builtinDefaultIcon = Codicon.tasklist; +const builtinDefaultIcon = (mode: IChatMode) => { + switch (mode.name.get().toLowerCase()) { + case 'ask': return Codicon.ask; + case 'plan': return Codicon.tasklist; + default: return undefined; + } +}; export class ModePickerActionItem extends ChatInputPickerActionViewItem { constructor( @@ -165,7 +171,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { ...makeAction(mode, currentMode), tooltip: '', hover: { content: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip, position: this.pickerOptions.hoverPosition }, - icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon : undefined), + icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon(mode) : undefined), category: agentModeDisabledViaPolicy ? policyDisabledCategory : customCategory }; }; @@ -203,7 +209,18 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const shouldHideEditMode = configurationService.getValue(ChatConfiguration.EditModeHidden) && chatAgentService.hasToolsAgent && currentMode.id !== ChatMode.Edit.id; - const otherBuiltinModes = modes.builtin.filter(mode => mode.id !== ChatMode.Agent.id && !(shouldHideEditMode && mode.id === ChatMode.Edit.id)); + const otherBuiltinModes = modes.builtin.filter(mode => { + if (mode.id === ChatMode.Agent.id) { + return false; + } + if (shouldHideEditMode && mode.id === ChatMode.Edit.id) { + return false; + } + if (mode.id === ChatMode.Ask.id) { + return false; + } + return true; + }); // Filter out 'implement' mode from the dropdown - it's available for handoffs but not user-selectable const customModes = groupBy( modes.custom, @@ -267,7 +284,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { // Every built-in mode should have an icon. // TODO: this should be provided by the mode itself if (!icon && isModeConsideredBuiltIn(currentMode, this._productService)) { - icon = builtinDefaultIcon; + icon = builtinDefaultIcon(currentMode); } const labelElements = []; @@ -284,21 +301,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } } -/** - * Returns true if the mode is the built-in 'implement' mode from the chat extension. - * This mode is hidden from the mode picker but available for handoffs. - */ -export function isBuiltinImplementMode(mode: IChatMode, productService: IProductService): boolean { - if (mode.name.get().toLowerCase() !== 'implement') { - return false; - } - if (mode.source?.storage !== PromptsStorage.extension) { - return false; - } - const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && mode.source.extensionId.value === chatExtensionId; -} - function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { if (mode.isBuiltin) { return true; From 0d12078d05d3442f99c17df2b4335ce274c3f5cf Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:54:58 +0000 Subject: [PATCH 34/64] Update secondary hover background colors for consistency in the 2026 Light theme --- extensions/theme-2026/themes/2026-light.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 2d4524f4947..23f1449887b 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -23,7 +23,7 @@ "button.border": "#F2F3F4FF", "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#E6E6E6", + "button.secondaryHoverBackground": "#F3F3F3", "checkbox.background": "#EDEDED", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#202020", @@ -64,7 +64,7 @@ "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#E0E0E0", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F7F7F7", + "list.hoverBackground": "#F3F3F3", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -106,7 +106,7 @@ "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", - "commandCenter.activeBackground": "#F7F7F7", + "commandCenter.activeBackground": "#F3F3F3", "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", @@ -127,7 +127,7 @@ "editorLink.activeForeground": "#0069CC", "editorWhitespace.foreground": "#66666640", "editorIndentGuide.background": "#F7F7F740", - "editorIndentGuide.activeBackground": "#F7F7F7", + "editorIndentGuide.activeBackground": "#F3F3F3", "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#0069CC40", @@ -179,8 +179,8 @@ "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#666666", - "statusBarItem.activeBackground": "#E6E6E6", - "statusBarItem.hoverBackground": "#F7F7F7", + "statusBarItem.activeBackground": "#F3F3F3", + "statusBarItem.hoverBackground": "#F3F3F3", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", @@ -193,7 +193,7 @@ "tab.lastPinnedBorder": "#F2F3F4FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#F7F7F7", + "tab.hoverBackground": "#F3F3F3", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", "tab.unfocusedActiveForeground": "#666666", From 85e3eef75f0dec998e0af74f3e4663e84beec6e6 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:59:02 +0000 Subject: [PATCH 35/64] Add border and box-shadow to notifications center for improved visibility --- extensions/theme-2026/themes/styles.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index ba7b49f8c63..54cafd922ad 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -271,6 +271,8 @@ backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; + border: 1px solid var(--vscode-editorWidget-border) !important; + box-shadow: var(--shadow-lg) !important; } .monaco-workbench.vs-dark .notifications-center { From 48bfa565862a3004f721620cf622937d10edded1 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 16:02:05 +0000 Subject: [PATCH 36/64] Update border-radius to use variable for consistency in chat input styles --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 952b37142f1..004b2fd6eca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1239,14 +1239,14 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .monaco-scrollable-element .monaco-list-rows .monaco-list-row { - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .interactive-input-part.compact .chat-input-container { display: flex; justify-content: space-between; padding-bottom: 0; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .interactive-input-and-side-toolbar { From ba35bc88a71572aa5839bae94910aaa71cce1a5e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:23:47 -0800 Subject: [PATCH 37/64] Revert "Merge pull request #294104 from microsoft/tyriar/revert" This reverts commit a8f61833a363b30e231ac4455a0de1d3ab23fb78, reversing changes made to 7e883bb39849880de7e865720ba34e649cb6a0ca. --- package-lock.json | 96 +++++++++---------- package.json | 20 ++-- remote/package-lock.json | 96 +++++++++---------- remote/package.json | 20 ++-- remote/web/package-lock.json | 88 ++++++++--------- remote/web/package.json | 18 ++-- .../terminal/browser/xterm/xtermTerminal.ts | 6 +- 7 files changed, 173 insertions(+), 171 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49beda3da1a..7c6806f2b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,16 +30,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -3895,30 +3895,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3928,7 +3928,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3950,63 +3950,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index d7794f4e979..e79cfc22656 100644 --- a/package.json +++ b/package.json @@ -92,16 +92,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index a6c0504563b..de6d200c162 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -577,30 +577,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -610,67 +610,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index e90da38f129..603dc5559ba 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index a5d7be69eff..e83d2319329 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -99,30 +99,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -132,58 +132,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 4bf2586bc81..f738d5554fa 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 96098673241..d66f1c39a38 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -244,9 +244,11 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, - overviewRuler: options.disableOverviewRuler ? { width: 0 } : { + scrollbar: options.disableOverviewRuler ? undefined : { width: 14, - showTopBorder: true, + overviewRuler: { + showTopBorder: true, + }, }, ignoreBracketedPasteMode: config.ignoreBracketedPasteMode, rescaleOverlappingGlyphs: config.rescaleOverlappingGlyphs, From 03bd51cfa3584a8116b523a9e9fa852ca4ae2553 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 10 Feb 2026 16:26:10 +0000 Subject: [PATCH 38/64] Move PreToolUse to extension (#294042) * Move PreToolUse to extension * Migrate PostToolUse --- .../common/extensionsApiProposals.ts | 2 +- .../api/common/extHostLanguageModelTools.ts | 1 + .../tools/languageModelToolsService.ts | 160 +---- .../chat/common/hooks/hooksCommandTypes.ts | 60 -- .../common/hooks/hooksExecutionService.ts | 199 +---- .../contrib/chat/common/hooks/hooksTypes.ts | 90 +-- .../common/tools/languageModelToolsService.ts | 12 + .../tools/languageModelToolsService.test.ts | 303 ++------ .../test/common/hooksExecutionService.test.ts | 678 ------------------ ...scode.proposed.chatParticipantPrivate.d.ts | 14 +- 10 files changed, 127 insertions(+), 1392 deletions(-) diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 8769eef63c2..fa429fbf424 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -56,7 +56,7 @@ const _allApiProposals = { }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 12 + version: 13 }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 68b95881b32..6095aca4be1 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -127,6 +127,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, + preToolUseResult: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.preToolUseResult : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index aba6e221b02..4bc871cae89 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -20,6 +20,7 @@ import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, Ob import Severity from '../../../../../base/common/severity.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; @@ -35,8 +36,6 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; -import { IPostToolUseCallerInput, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js'; -import { HookAbortError, IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; @@ -45,12 +44,11 @@ import { ChatConfiguration } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; -import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolContentToA11yString, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel, IToolInvokedEvent } from '../../common/tools/languageModelToolsService.js'; -import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; -import { URI } from '../../../../../base/common/uri.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -126,7 +124,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, @ICommandService private readonly _commandService: ICommandService, ) { super(); @@ -367,74 +364,35 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * Execute the preToolUse hook and handle denial. - * Returns an object containing: - * - denialResult: A tool result if the hook denied execution (caller should return early) - * - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions) - * @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one. - */ - private async _executePreToolUseHook( + private _handlePreToolUseDenial( dto: IToolInvocation, + hookResult: IExternalPreToolUseHookResult, toolData: IToolData | undefined, - request: IChatRequestModel | undefined, pendingInvocation: ChatToolInvocation | undefined, - token: CancellationToken - ): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> { - // Skip hook if no session context or tool doesn't exist - if (!dto.context?.sessionResource || !toolData) { - return {}; + request: IChatRequestModel | undefined, + ): IToolResult { + const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); + const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); + + if (toolData) { + if (pendingInvocation) { + pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); + } else if (request) { + const cancelledInvocation = ChatToolInvocation.createCancelled( + { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, + dto.parameters, + ToolConfirmKind.Denied, + reason + ); + this._chatService.appendProgress(request, cancelledInvocation); + } } - const hookInput: IPreToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - toolCallId: dto.callId, + return { + content: [{ kind: 'text', value: `Tool execution denied: ${hookReason}` }], + toolResultError: hookReason, }; - let hookResult: IPreToolUseHookResult | undefined; - try { - hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); - } catch (e) { - if (e instanceof HookAbortError) { - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} aborted by preToolUse hook: ${e.stopReason}`); - throw new CancellationError(); - } - throw e; - } - - if (hookResult?.permissionDecision === 'deny') { - const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); - const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); - - // Handle the tool invocation in cancelled state - if (toolData) { - if (pendingInvocation) { - // If there's an existing streaming invocation, cancel it - pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); - } else if (request) { - // Otherwise create a new cancelled invocation and add it to the chat model - const toolInvocation = ChatToolInvocation.createCancelled( - { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, - dto.parameters, - ToolConfirmKind.Denied, - reason - ); - this._chatService.appendProgress(request, toolInvocation); - } - } - - const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason); - return { - denialResult: { - content: [{ kind: 'text', value: denialMessage }], - toolResultError: hookReason, - }, - hookResult, - }; - } - - return { hookResult }; } /** @@ -469,53 +427,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * Execute the postToolUse hook after tool completion. - * If the hook returns a "block" decision, additional context is appended to the tool result - * as feedback for the agent indicating the block and reason. The tool has already run, - * so blocking only provides feedback. - */ - private async _executePostToolUseHook( - dto: IToolInvocation, - toolResult: IToolResult, - token: CancellationToken - ): Promise { - if (!dto.context?.sessionResource) { - return; - } - - const hookInput: IPostToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - getToolResponseText: () => toolContentToA11yString(toolResult.content), - toolCallId: dto.callId, - }; - let hookResult; - try { - hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); - } catch (e) { - if (e instanceof HookAbortError) { - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook aborted for tool ${dto.toolId}: ${e.stopReason}`); - throw new CancellationError(); - } - throw e; - } - - if (hookResult?.decision === 'block') { - const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result"); - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook blocked for tool ${dto.toolId}: ${hookReason}`); - const blockMessage = localize('postToolUseHookBlockedContext', "The PostToolUse hook blocked this tool result. Reason: {0}", hookReason); - toolResult.content.push({ kind: 'text', value: '\n\n' + blockMessage + '\n' }); - } - - if (hookResult?.additionalContext) { - // Append additional context from all hooks to the tool result content - for (const context of hookResult.additionalContext) { - toolResult.content.push({ kind: 'text', value: '\n\n' + context + '\n' }); - } - } - } - async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); @@ -563,14 +474,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo token = source.token; } - // Execute preToolUse hook - returns early if hook denies execution - const { denialResult: hookDenialResult, hookResult: preToolUseHookResult } = await this._executePreToolUseHook(dto, toolData, request, toolInvocation, token); - if (hookDenialResult) { - // Clean up pending tool call if it exists + // Handle preToolUse hook denial + const preToolUseHookResult = dto.preToolUseResult; + if (preToolUseHookResult?.permissionDecision === 'deny') { + const denialResult = this._handlePreToolUseDenial(dto, preToolUseHookResult, toolData, toolInvocation, request); if (pendingToolCallKey) { this._pendingToolCalls.delete(pendingToolCallKey); } - return hookDenialResult; + return denialResult; } // Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema @@ -730,9 +641,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - // Execute postToolUse hook after successful tool execution - await this._executePostToolUseHook(dto, toolResult, token); - this._telemetryService.publicLog2( 'languageModelToolInvoked', { @@ -777,7 +685,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IPreToolUseHookResult | undefined, token: CancellationToken): Promise { + private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IExternalPreToolUseHookResult | undefined, token: CancellationToken): Promise { let forceConfirmationReason: string | undefined; if (hookResult?.permissionDecision === 'ask') { const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse); @@ -798,7 +706,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * since when the hook returns 'ask' and preparedInvocation was undefined, we create one. */ private async resolveAutoConfirmFromHook( - hookResult: IPreToolUseHookResult | undefined, + hookResult: IExternalPreToolUseHookResult | undefined, tool: IToolEntry, dto: IToolInvocation, preparedInvocation: IPreparedToolInvocation | undefined, diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts index b939a402040..1b31c0fafd5 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts @@ -9,10 +9,6 @@ * "External" means these types define the contract between VS Code and the external hook * command process. * - * Examples: - * - IPreToolUseCommandInput: sent TO the spawned command via stdin - * - IPreToolUseCommandOutput: received FROM the spawned command via stdout - * * Internal types (in hooksTypes.ts) are used within VS Code. */ @@ -69,59 +65,3 @@ export interface IHookCommandResult { } //#endregion - -//#region PreToolUse Hook Types - -/** - * Tool-specific command input fields for preToolUse hook. - * These are mixed with IHookCommandInput at runtime. - */ -export interface IPreToolUseCommandInput { - readonly tool_name: string; - readonly tool_input: unknown; - readonly tool_use_id: string; -} - -/** - * External command output for preToolUse hook. - * Extends common output with hookSpecificOutput wrapper. - */ -export interface IPreToolUseCommandOutput extends IHookCommandOutput { - readonly hookSpecificOutput?: { - readonly hookEventName?: string; - readonly permissionDecision?: 'allow' | 'deny'; - readonly permissionDecisionReason?: string; - readonly updatedInput?: object; - readonly additionalContext?: string; - }; -} - -//#endregion - -//#region PostToolUse Hook Types - -/** - * Tool-specific command input fields for postToolUse hook. - * These are mixed with IHookCommandInput at runtime. - */ -export interface IPostToolUseCommandInput { - readonly tool_name: string; - readonly tool_input: unknown; - readonly tool_response: string; - readonly tool_use_id: string; -} - -/** - * External command output for postToolUse hook. - * Extends common output with decision control fields. - */ -export interface IPostToolUseCommandOutput extends IHookCommandOutput { - readonly decision?: 'block'; - readonly reason?: string; - readonly hookSpecificOutput?: { - readonly hookEventName?: string; - readonly additionalContext?: string; - }; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts index 266ce042a49..5e6f02e05b4 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts @@ -13,24 +13,15 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ILogService } from '../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; +import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; import { HookCommandResultKind, IHookCommandInput, IHookCommandResult, - IPostToolUseCommandInput, - IPreToolUseCommandInput } from './hooksCommandTypes.js'; import { commonHookOutputValidator, IHookResult, - IPostToolUseCallerInput, - IPostToolUseHookResult, - IPreToolUseCallerInput, - IPreToolUseHookResult, - postToolUseOutputValidator, - PreToolUsePermissionDecision, - preToolUseOutputValidator } from './hooksTypes.js'; export const hooksOutputChannelId = 'hooksExecution'; @@ -101,21 +92,6 @@ export interface IHooksExecutionService { * Execute hooks of the given type for the given session */ executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; - - /** - * Execute preToolUse hooks with typed input and validated output. - * The execution service builds the full hook input from the caller input plus session context. - * Returns a combined result with common fields and permission decision. - */ - executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise; - - /** - * Execute postToolUse hooks with typed input and validated output. - * Called after a tool completes successfully. The execution service builds the full hook input - * from the caller input plus session context. - * Returns a combined result with decision and additional context. - */ - executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise; } /** @@ -439,6 +415,15 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } + // Emit aggregated warnings for any hook results that had warning messages + this._emitAggregatedWarnings(hookType, sessionResource, results); + + // If any hook set stopReason, emit progress so it's visible to the user + const stoppedResult = results.find(r => r.stopReason !== undefined); + if (stoppedResult?.stopReason) { + this._emitHookProgress(hookType, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); + } + return results; } finally { this._onDidExecuteHook.fire({ @@ -451,170 +436,6 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } - async executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise { - const toolSpecificInput: IPreToolUseCommandInput = { - tool_name: input.toolName, - tool_input: input.toolInput, - tool_use_id: input.toolCallId, - }; - - const results = await this.executeHook(HookType.PreToolUse, sessionResource, { - input: toolSpecificInput, - token: token ?? CancellationToken.None, - }); - - // Run all hooks and collapse results. Most restrictive decision wins: deny > ask > allow. - // Collect all additionalContext strings from every hook. - const allAdditionalContext: string[] = []; - let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined; - let winningResult: IHookResult | undefined; - let winningReason: string | undefined; - let lastUpdatedInput: object | undefined; - - for (const result of results) { - if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) { - const validationResult = preToolUseOutputValidator.validate(result.output); - if (!validationResult.error) { - const hookSpecificOutput = validationResult.content.hookSpecificOutput; - if (hookSpecificOutput) { - // Validate hookEventName if present - must match the hook type - if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== HookType.PreToolUse) { - this._logService.warn(`[HooksExecutionService] preToolUse hook returned invalid hookEventName '${hookSpecificOutput.hookEventName}', expected '${HookType.PreToolUse}'`); - continue; - } - - // Collect additionalContext from every hook - if (hookSpecificOutput.additionalContext) { - allAdditionalContext.push(hookSpecificOutput.additionalContext); - } - - // Track the last updatedInput (later hooks override earlier ones) - if (hookSpecificOutput.updatedInput) { - lastUpdatedInput = hookSpecificOutput.updatedInput; - } - - // Track the most restrictive decision: deny > ask > allow - const decision = hookSpecificOutput.permissionDecision; - if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) { - mostRestrictiveDecision = decision; - winningResult = result; - winningReason = hookSpecificOutput.permissionDecisionReason; - } - } - } else { - this._logService.warn(`[HooksExecutionService] preToolUse hook output validation failed: ${validationResult.error.message}`); - } - } - } - - const baseResult = winningResult ?? results[0]; - - // Emit hook progress for warning messages after all hooks have completed - this._emitAggregatedWarnings(HookType.PreToolUse, sessionResource, results); - - // If any hook set stopReason, throw HookAbortError after processing - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason !== undefined) { - this._emitHookProgress(HookType.PreToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - throw new HookAbortError(HookType.PreToolUse, stoppedResult.stopReason ?? 'Unknown error'); - } - - return { - ...baseResult, - permissionDecision: mostRestrictiveDecision, - permissionDecisionReason: winningReason, - updatedInput: lastUpdatedInput, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } - - /** - * Returns true if `candidate` is more restrictive than `current`. - * Restriction order: deny > ask > allow. - */ - private _isMoreRestrictive(candidate: PreToolUsePermissionDecision, current: PreToolUsePermissionDecision | undefined): boolean { - const order: Record = { 'deny': 2, 'ask': 1, 'allow': 0 }; - return current === undefined || order[candidate] > order[current]; - } - - async executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise { - // Check if there are PostToolUse hooks registered before doing any work stringifying tool results - const hooks = this.getHooksForSession(sessionResource); - const hookCommands = hooks?.[HookType.PostToolUse]; - if (!hookCommands || hookCommands.length === 0) { - return undefined; - } - - // Lazily render tool response text only when hooks are registered - const toolResponseText = input.getToolResponseText(); - - const toolSpecificInput: IPostToolUseCommandInput = { - tool_name: input.toolName, - tool_input: input.toolInput, - tool_response: toolResponseText, - tool_use_id: input.toolCallId, - }; - - const results = await this.executeHook(HookType.PostToolUse, sessionResource, { - input: toolSpecificInput, - token: token ?? CancellationToken.None, - }); - - // Run all hooks and collapse results. Block is the most restrictive decision. - // Collect all additionalContext strings from every hook. - const allAdditionalContext: string[] = []; - let hasBlock = false; - let blockReason: string | undefined; - let blockResult: IHookResult | undefined; - - for (const result of results) { - if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) { - const validationResult = postToolUseOutputValidator.validate(result.output); - if (!validationResult.error) { - const validated = validationResult.content; - - // Validate hookEventName if present - if (validated.hookSpecificOutput?.hookEventName !== undefined && validated.hookSpecificOutput.hookEventName !== HookType.PostToolUse) { - this._logService.warn(`[HooksExecutionService] postToolUse hook returned invalid hookEventName '${validated.hookSpecificOutput.hookEventName}', expected '${HookType.PostToolUse}'`); - continue; - } - - // Collect additionalContext from every hook - if (validated.hookSpecificOutput?.additionalContext) { - allAdditionalContext.push(validated.hookSpecificOutput.additionalContext); - } - - // Track the first block decision (most restrictive) - if (validated.decision === 'block' && !hasBlock) { - hasBlock = true; - blockReason = validated.reason; - blockResult = result; - } - } else { - this._logService.warn(`[HooksExecutionService] postToolUse hook output validation failed: ${validationResult.error.message}`); - } - } - } - - const baseResult = blockResult ?? results[0]; - - // Emit hook progress for warning messages after all hooks have completed - this._emitAggregatedWarnings(HookType.PostToolUse, sessionResource, results); - - // If any hook set stopReason, throw HookAbortError after processing - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason !== undefined) { - this._emitHookProgress(HookType.PostToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - throw new HookAbortError(HookType.PostToolUse, stoppedResult.stopReason ?? 'Unknown error'); - } - - return { - ...baseResult, - decision: hasBlock ? 'block' : undefined, - reason: blockReason, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } } /** diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts index f35308d8935..1c4bceb5314 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts @@ -9,14 +9,10 @@ * "Internal" means these types are used by VS Code code only - they never cross the * process boundary to external hook commands. They use camelCase for field names. * - * Examples: - * - IPreToolUseCallerInput: provided by VS Code callers (e.g., LanguageModelToolsService) - * - IPreToolUseHookResult: returned TO VS Code callers after processing command output - * * External types (in hooksCommandTypes.ts) define the contract with spawned commands. */ -import { vBoolean, vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js'; +import { vBoolean, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; //#region Common Hook Types @@ -58,87 +54,3 @@ export const commonHookOutputValidator = vObj({ }); //#endregion - -//#region PreToolUse Hook Types - -/** - * Input provided by VS Code callers when invoking the preToolUse hook. - */ -export interface IPreToolUseCallerInput { - readonly toolName: string; - readonly toolInput: unknown; - readonly toolCallId: string; -} - -export const preToolUseOutputValidator = vObj({ - hookSpecificOutput: vOptionalProp(vObj({ - hookEventName: vOptionalProp(vString()), - permissionDecision: vOptionalProp(vEnum('allow', 'deny', 'ask')), - permissionDecisionReason: vOptionalProp(vString()), - updatedInput: vOptionalProp(vObjAny()), - additionalContext: vOptionalProp(vString()), - })), -}); - -/** - * Valid permission decisions for preToolUse hooks. - * - 'allow': Auto-approve the tool execution (skip user confirmation) - * - 'deny': Deny the tool execution - * - 'ask': Always require user confirmation (never auto-approve) - */ -export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; - -/** - * Result from preToolUse hooks with permission decision fields. - * Returned to VS Code callers. Represents the collapsed result of all hooks. - */ -export interface IPreToolUseHookResult extends IHookResult { - readonly permissionDecision?: PreToolUsePermissionDecision; - readonly permissionDecisionReason?: string; - /** - * Modified tool input parameters from the hook. - * When set, replaces the original tool input before execution. - * Combine with 'allow' to auto-approve, or 'ask' to show modified input to the user. - */ - readonly updatedInput?: object; - readonly additionalContext?: string[]; -} - -//#endregion - -//#region PostToolUse Hook Types - -/** - * Input provided by VS Code callers when invoking the postToolUse hook. - * The toolResponse is a lazy getter that renders the tool result content to a string. - * It is only called if there are PostToolUse hooks registered. - */ -export interface IPostToolUseCallerInput { - readonly toolName: string; - readonly toolInput: unknown; - readonly getToolResponseText: () => string; - readonly toolCallId: string; -} - -export const postToolUseOutputValidator = vObj({ - decision: vOptionalProp(vEnum('block')), - reason: vOptionalProp(vString()), - hookSpecificOutput: vOptionalProp(vObj({ - hookEventName: vOptionalProp(vString()), - additionalContext: vOptionalProp(vString()), - })), -}); - -export type PostToolUseDecision = 'block'; - -/** - * Result from postToolUse hooks with decision fields. - * Returned to VS Code callers. Represents the collapsed result of all hooks. - */ -export interface IPostToolUseHookResult extends IHookResult { - readonly decision?: PostToolUseDecision; - readonly reason?: string; - readonly additionalContext?: string[]; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 05a5d1d1752..9153ccc9fac 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -162,6 +162,16 @@ export namespace ToolDataSource { } } +/** + * Pre-tool-use hook result passed from the extension when the hook was executed externally. + */ +export interface IExternalPreToolUseHookResult { + permissionDecision?: 'allow' | 'deny' | 'ask'; + permissionDecisionReason?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedInput?: Record; +} + export interface IToolInvocation { callId: string; toolId: string; @@ -184,6 +194,8 @@ export interface IToolInvocation { userSelectedTools?: UserSelectedTools; /** The label of the custom button selected by the user during confirmation, if custom buttons were used. */ selectedCustomButton?: string; + /** Pre-tool-use hook result passed from the extension, if the hook was already executed externally. */ + preToolUseResult?: IExternalPreToolUseHookResult; } export interface IToolInvocationContext { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 3913649070d..a5b61a26879 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -34,7 +34,7 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { IHookResult, IPostToolUseCallerInput, IPostToolUseHookResult, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../../common/hooks/hooksTypes.js'; +import { IHookResult } from '../../../common/hooks/hooksTypes.js'; import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js'; import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -69,10 +69,6 @@ class MockHooksExecutionService implements IHooksExecutionService { readonly _serviceBrand: undefined; readonly onDidExecuteHook = Event.None; readonly onDidHookProgress = Event.None; - public preToolUseHookResult: IPreToolUseHookResult | undefined = undefined; - public postToolUseHookResult: IPostToolUseHookResult | undefined = undefined; - public lastPreToolUseInput: IPreToolUseCallerInput | undefined = undefined; - public lastPostToolUseInput: IPostToolUseCallerInput | undefined = undefined; setProxy(_proxy: IHooksExecutionProxy): void { } registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } @@ -80,14 +76,6 @@ class MockHooksExecutionService implements IHooksExecutionService { executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { return Promise.resolve([]); } - async executePreToolUseHook(_sessionResource: URI, input: IPreToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPreToolUseInput = input; - return this.preToolUseHookResult; - } - async executePostToolUseHook(_sessionResource: URI, input: IPostToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPostToolUseInput = input; - return this.postToolUseHookResult; - } } function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { @@ -3825,13 +3813,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook denies, tool returns error and creates cancelled invocation', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'deny', - permissionDecisionReason: 'Destructive operations require approval', - }; - const tool = registerToolForTest(hookService, store, 'hookDenyTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'should not run' }] }) }); @@ -3839,8 +3820,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }); + dto.preToolUseResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Destructive operations require approval', + }; + const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }), + dto, async () => 0, CancellationToken.None ); @@ -3863,12 +3850,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook allows, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - const tool = registerToolForTest(hookService, store, 'hookAllowTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3876,8 +3857,13 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-allow', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + }; + const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }), + dto, async () => 0, CancellationToken.None ); @@ -3888,8 +3874,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns undefined, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = undefined; - const tool = registerToolForTest(hookService, store, 'hookUndefinedTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3906,38 +3890,7 @@ suite('LanguageModelToolsService', () => { assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); }); - test('hook receives correct input parameters', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - - const tool = registerToolForTest(hookService, store, 'hookInputTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) - }); - - stubGetSession(hookChatService, 'hook-test-input', { requestId: 'req1' }); - - await hookService.invokeTool( - tool.makeDto({ param1: 'value1', param2: 42 }, { sessionId: 'hook-test-input' }), - async () => 0, - CancellationToken.None - ); - - assert.ok(mockHooksService.lastPreToolUseInput); - assert.strictEqual(mockHooksService.lastPreToolUseInput.toolName, 'hookInputTool'); - assert.deepStrictEqual(mockHooksService.lastPreToolUseInput.toolInput, { param1: 'value1', param2: 42 }); - }); - test('when hook denies, tool invoke is never called', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'deny', - permissionDecisionReason: 'Operation not allowed', - }; - let invokeCalled = false; const tool = registerToolForTest(hookService, store, 'hookNeverInvokeTool', { invoke: async () => { @@ -3949,8 +3902,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: unknown } = {}; stubGetSession(hookChatService, 'hook-test-no-invoke', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }); + dto.preToolUseResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Operation not allowed', + }; + await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }), + dto, async () => 0, CancellationToken.None ); @@ -3959,13 +3918,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns ask, tool is not auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user confirmation', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAskTool', { invoke: async () => { @@ -3984,9 +3936,15 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-ask', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-ask' }); + dto.preToolUseResult = { + permissionDecision: 'ask', + permissionDecisionReason: 'Requires user confirmation', + }; + // Start invocation - it should wait for confirmation const invokePromise = hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-ask' }), + dto, async () => 0, CancellationToken.None ); @@ -4007,12 +3965,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns allow, tool is auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAutoApproveTool', { invoke: async () => { @@ -4031,9 +3983,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-auto-approve', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-auto-approve' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + }; + // Invoke the tool - it should auto-approve due to hook const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-auto-approve' }), + dto, async () => 0, CancellationToken.None ); @@ -4046,12 +4003,6 @@ suite('LanguageModelToolsService', () => { test('when hook returns updatedInput, tool is invoked with replaced parameters', async () => { let receivedParameters: Record | undefined; - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { safeCommand: 'echo hello' }, - }; const tool = registerToolForTest(hookService, store, 'hookUpdatedInputTool', { invoke: async (dto) => { @@ -4069,8 +4020,14 @@ suite('LanguageModelToolsService', () => { stubGetSession(hookChatService, 'hook-test-updated-input', { requestId: 'req1' }); + const dto = tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { safeCommand: 'echo hello' }, + }; + await hookService.invokeTool( - tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }), + dto, async () => 0, CancellationToken.None ); @@ -4095,12 +4052,6 @@ suite('LanguageModelToolsService', () => { }); let receivedParameters: Record | undefined; - mockHooks.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { invalidField: 'wrong' }, - }; const tool = registerToolForTest(setup.service, store, 'hookValidationFailTool', { invoke: async (dto) => { @@ -4124,8 +4075,14 @@ suite('LanguageModelToolsService', () => { stubGetSession(setup.chatService, 'hook-test-validation-fail', { requestId: 'req1' }); + const dto = tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { invalidField: 'wrong' }, + }; + await setup.service.invokeTool( - tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }), + dto, async () => 0, CancellationToken.None ); @@ -4151,12 +4108,6 @@ suite('LanguageModelToolsService', () => { }); let receivedParameters: Record | undefined; - mockHooks.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { command: 'safe-command' }, - }; const tool = registerToolForTest(setup.service, store, 'hookValidationPassTool', { invoke: async (dto) => { @@ -4180,8 +4131,14 @@ suite('LanguageModelToolsService', () => { stubGetSession(setup.chatService, 'hook-test-validation-pass', { requestId: 'req1' }); + const dto = tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { command: 'safe-command' }, + }; + await setup.service.invokeTool( - tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }), + dto, async () => 0, CancellationToken.None ); @@ -4190,154 +4147,4 @@ suite('LanguageModelToolsService', () => { assert.deepStrictEqual(receivedParameters, { command: 'safe-command' }); }); }); - - suite('postToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; - let hookService: LanguageModelToolsService; - let hookChatService: MockChatService; - - setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); - hookService = setup.service; - hookChatService = setup.chatService; - }); - - test('when hook blocks, block context is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - decision: 'block', - reason: 'Lint errors detected', - }; - - const tool = registerToolForTest(hookService, store, 'postHookBlockTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-block', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block' }), - async () => 0, - CancellationToken.None - ); - - // Original content should still be present - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - - // Block context should be appended wrapped in XML tags - assert.ok(result.content.length >= 2, 'Block context should be appended'); - const blockPart = result.content[1] as IToolResultTextPart; - assert.strictEqual(blockPart.kind, 'text'); - assert.ok(blockPart.value.includes(''), 'Block text should have opening tag'); - assert.ok(blockPart.value.includes(''), 'Block text should have closing tag'); - assert.ok(blockPart.value.includes('Lint errors detected'), 'Block text should include the reason'); - - // Should NOT set toolResultError - assert.strictEqual(result.toolResultError, undefined); - }); - - test('when hook returns additionalContext, it is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - additionalContext: ['Consider running tests after this change'], - }; - - const tool = registerToolForTest(hookService, store, 'postHookContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-context', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-context' }), - async () => 0, - CancellationToken.None - ); - - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - - assert.ok(result.content.length >= 2, 'Additional context should be appended'); - const contextPart = result.content[1] as IToolResultTextPart; - assert.strictEqual(contextPart.kind, 'text'); - assert.ok(contextPart.value.includes(''), 'Context text should have opening tag'); - assert.ok(contextPart.value.includes(''), 'Context text should have closing tag'); - assert.ok(contextPart.value.includes('Consider running tests after this change')); - }); - - test('when hook returns undefined, tool result is unchanged', async () => { - mockHooksService.postToolUseHookResult = undefined; - - const tool = registerToolForTest(hookService, store, 'postHookNoopTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-noop', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-noop' }), - async () => 0, - CancellationToken.None - ); - - assert.strictEqual(result.content.length, 1); - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - }); - - test('hook receives correct input including tool response text', async () => { - mockHooksService.postToolUseHookResult = undefined; - - const tool = registerToolForTest(hookService, store, 'postHookInputTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'file contents here' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-input', { requestId: 'req1' }); - - await hookService.invokeTool( - tool.makeDto({ param1: 'value1' }, { sessionId: 'post-hook-input' }), - async () => 0, - CancellationToken.None - ); - - assert.ok(mockHooksService.lastPostToolUseInput); - assert.strictEqual(mockHooksService.lastPostToolUseInput.toolName, 'postHookInputTool'); - assert.deepStrictEqual(mockHooksService.lastPostToolUseInput.toolInput, { param1: 'value1' }); - assert.strictEqual(typeof mockHooksService.lastPostToolUseInput.getToolResponseText, 'function'); - }); - - test('when hook blocks with both decision and additionalContext, both are appended', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - decision: 'block', - reason: 'Security issue found', - additionalContext: ['Please review the file permissions'], - }; - - const tool = registerToolForTest(hookService, store, 'postHookBlockContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-block-ctx', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block-ctx' }), - async () => 0, - CancellationToken.None - ); - - // Original + block message + additional context = 3 parts - assert.ok(result.content.length >= 3, 'Should have original, block message, and additional context'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original'); - assert.ok((result.content[1] as IToolResultTextPart).value.includes('Security issue found')); - assert.ok((result.content[2] as IToolResultTextPart).value.includes('Please review the file permissions')); - }); - }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts index 43f2f82410d..1493f35a29a 100644 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -389,684 +389,6 @@ suite('HooksExecutionService', () => { }); }); - suite('executePreToolUseHook', () => { - test('returns allow result when hook allows', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - permissionDecisionReason: 'Tool is safe' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - assert.strictEqual(result.permissionDecisionReason, 'Tool is safe'); - }); - - test('returns ask result when hook requires confirmation', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user approval' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Requires user approval'); - }); - - test('deny takes priority over ask and allow', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - // First hook returns allow, second returns ask, third returns deny - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow' } } - }; - } else if (callCount === 2) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'ask' } } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2'), cmd('hook3')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'deny'); - assert.strictEqual(result.permissionDecisionReason, 'Blocked'); - }); - - test('ask takes priority over allow', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - // First hook returns allow, second returns ask - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow' } } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Need confirmation' } } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Need confirmation'); - }); - - test('ignores results with wrong hookEventName', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - // First hook returns allow but with wrong hookEventName - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', // Wrong hook type - permissionDecision: 'deny' - } - } - }; - } else { - // Second hook returns allow with correct hookEventName - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'allow' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - // The deny with wrong hookEventName should be ignored - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - }); - - test('allows results without hookEventName (optional field)', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - // No hookEventName - should be accepted - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - }); - - test('returns updatedInput when hook provides it', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - updatedInput: { path: '/safe/path.ts' } - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: { path: '/original/path.ts' }, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - assert.deepStrictEqual(result.updatedInput, { path: '/safe/path.ts' }); - }); - - test('later hook updatedInput overrides earlier one', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } } - }; - } - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.updatedInput, { value: 'second' }); - }); - - test('returns result with updatedInput even without permission decision', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - updatedInput: { modified: true } - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.updatedInput, { modified: true }); - assert.strictEqual(result.permissionDecision, undefined); - }); - - test('updatedInput combined with ask shows modified input to user', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'ask', - permissionDecisionReason: 'Modified input needs review', - updatedInput: { command: 'echo safe' } - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: { command: 'rm -rf /' }, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Modified input needs review'); - assert.deepStrictEqual(result.updatedInput, { command: 'echo safe' }); - }); - }); - - suite('executePostToolUseHook', () => { - test('returns undefined when no hooks configured', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.strictEqual(result, undefined); - }); - - test('returns block decision when hook blocks', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Lint errors found' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.decision, 'block'); - assert.strictEqual(result.reason, 'Lint errors found'); - }); - - test('returns additionalContext from hookSpecificOutput', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'File was modified successfully' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.additionalContext, ['File was modified successfully']); - assert.strictEqual(result.decision, undefined); - }); - - test('block takes priority and collects all additionalContext', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Tests failed' - } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - additionalContext: 'Extra context from second hook' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.decision, 'block'); - assert.strictEqual(result.reason, 'Tests failed'); - assert.deepStrictEqual(result.additionalContext, ['Extra context from second hook']); - }); - - test('ignores results with wrong hookEventName', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: 'Should be ignored' - } - } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'Correct context' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.additionalContext, ['Correct context']); - }); - - test('passes tool response text as string to external command', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: {} }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executePostToolUseHook( - sessionUri, - { toolName: 'my-tool', toolInput: { arg: 'val' }, getToolResponseText: () => 'file contents here', toolCallId: 'call-42' } - ); - - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['tool_name'], 'my-tool'); - assert.deepStrictEqual(input['tool_input'], { arg: 'val' }); - assert.strictEqual(input['tool_response'], 'file contents here'); - assert.strictEqual(input['tool_use_id'], 'call-42'); - assert.strictEqual(input['hookEventName'], HookType.PostToolUse); - }); - - test('does not call getter when no PostToolUse hooks registered', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - // Register hooks only for PreToolUse, not PostToolUse - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - let getterCalled = false; - const result = await service.executePostToolUseHook( - sessionUri, - { - toolName: 'test-tool', - toolInput: {}, - getToolResponseText: () => { getterCalled = true; return ''; }, - toolCallId: 'call-1' - } - ); - - assert.strictEqual(result, undefined); - assert.strictEqual(getterCalled, false); - }); - }); - - suite('preToolUse smoke tests — input → output', () => { - test('single hook: allow', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - permissionDecisionReason: 'Trusted tool', - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('lint-check')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'readFile', toolInput: { path: '/src/index.ts' }, toolCallId: 'call-1' }; - const result = await service.executePreToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason, additionalContext: result?.additionalContext }), - JSON.stringify({ permissionDecision: 'allow', permissionDecisionReason: 'Trusted tool', additionalContext: undefined }) - ); - }); - - test('single hook: deny', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'deny', - permissionDecisionReason: 'Path is outside workspace', - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('path-guard')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'writeFile', toolInput: { path: '/etc/passwd' }, toolCallId: 'call-2' }; - const result = await service.executePreToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'Path is outside workspace' }) - ); - }); - - test('multiple hooks: deny wins over allow and ask', async () => { - // Three hooks return allow, ask, deny (in that order). - // deny must win regardless of ordering. - let callCount = 0; - const decisions = ['allow', 'ask', 'deny'] as const; - const proxy = createMockProxy(() => { - const decision = decisions[callCount++]; - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `hook-${callCount}` } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2'), cmd('h3')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'runCommand', toolInput: { cmd: 'rm -rf /' }, toolCallId: 'call-3' } - ); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'hook-3' }) - ); - }); - - test('multiple hooks: ask wins over allow', async () => { - let callCount = 0; - const decisions = ['allow', 'ask'] as const; - const proxy = createMockProxy(() => { - const decision = decisions[callCount++]; - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `reason-${decision}` } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'exec', toolInput: {}, toolCallId: 'call-4' } - ); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: 'reason-ask' }) - ); - }); - }); - - suite('postToolUse smoke tests — input → output', () => { - test('single hook: block', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Lint errors found' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('lint')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'writeFile', toolInput: { path: 'foo.ts' }, getToolResponseText: () => 'wrote 42 bytes', toolCallId: 'call-5' }; - const result = await service.executePostToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: 'block', reason: 'Lint errors found', additionalContext: undefined }) - ); - }); - - test('single hook: additionalContext only', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - additionalContext: 'Tests still pass after this edit' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('test-runner')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'editFile', toolInput: {}, getToolResponseText: () => 'ok', toolCallId: 'call-6' }; - const result = await service.executePostToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: undefined, reason: undefined, additionalContext: ['Tests still pass after this edit'] }) - ); - }); - - test('multiple hooks: block wins and all hooks run', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'Tests failed' } }; - } - return { kind: HookCommandResultKind.Success, result: { hookSpecificOutput: { additionalContext: 'context from second hook' } } }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('test'), cmd('lint')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'writeFile', toolInput: {}, getToolResponseText: () => 'data', toolCallId: 'call-7' } - ); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: 'block', reason: 'Tests failed', additionalContext: ['context from second hook'] }) - ); - }); - - test('no hooks registered → undefined (getter never called)', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - // Register PreToolUse only — no PostToolUse - store.add(service.registerHooks(sessionUri, { [HookType.PreToolUse]: [cmd('h')] })); - - let getterCalled = false; - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 't', toolInput: {}, getToolResponseText: () => { getterCalled = true; return ''; }, toolCallId: 'c' } - ); - - assert.strictEqual(result, undefined); - assert.strictEqual(getterCalled, false); - }); - }); - function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy { return { runHookCommand: async (hookCommand, input, token) => { diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 86dcf0337e4..ad37a1404ce 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 12 +// version: 13 declare module 'vscode' { @@ -258,6 +258,8 @@ declare module 'vscode' { provideFileIgnored(uri: Uri, token: CancellationToken): ProviderResult; } + export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; + export interface LanguageModelToolInvocationOptions { chatRequestId?: string; /** @deprecated Use {@link chatSessionResource} instead */ @@ -269,6 +271,16 @@ declare module 'vscode' { * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ subAgentInvocationId?: string; + /** + * Pre-tool-use hook result, if the hook was already executed by the caller. + * When provided, the tools service will skip executing its own preToolUse hook + * and use this result for permission decisions and input modifications instead. + */ + preToolUseResult?: { + permissionDecision?: PreToolUsePermissionDecision; + permissionDecisionReason?: string; + updatedInput?: object; + }; } export interface LanguageModelToolInvocationPrepareOptions { From 7393b0ec6071aca9ba95635a36588affb09db549 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:23:46 +0000 Subject: [PATCH 39/64] Fix chat session loss on workspace ID change (#289779) * Initial plan * Add migration logic to ChatSessionStore for workspace ID changes Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Improve workspace migration logic to handle empty window transitions Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Add tests for workspace migration in ChatSessionStore Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Address code review feedback: extract duplicate logic and improve tests Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Refactor to use onDidEnterWorkspace event for migration Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Fix test to use proper workspace identifier with uri property Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Address code review feedback: improve log consistency and remove unnecessary type assertion Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Clarify log message to indicate files are copied (not moved) Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Fix chatService tests by stubbing IWorkspaceEditingService Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Fix ChatEditingService test by stubbing IWorkspaceEditingService Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> Co-authored-by: Benjamin Pasero Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> Co-authored-by: Rob Lourens --- .../chat/common/model/chatSessionStore.ts | 97 ++++++++++++++++++- .../chatEditing/chatEditingService.test.ts | 5 + .../common/chatService/chatService.test.ts | 2 + .../common/model/chatSessionStore.test.ts | 90 ++++++++++++++++- 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 3bb3abaaafe..cecb075bd83 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -19,9 +19,10 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUserDataProfilesService } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, isEmptyWorkspaceIdentifier, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IWorkspaceEditingService } from '../../../../services/workspaces/common/workspaceEditing.js'; import { awaitStatsForSession } from '../chat.js'; import { IChatSessionStats, IChatSessionTiming, ResponseModelState } from '../chatService/chatService.js'; import { ChatAgentLocation } from '../constants.js'; @@ -36,7 +37,7 @@ const ChatIndexStorageKey = 'chat.ChatSessionStore.index'; const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex'; export class ChatSessionStore extends Disposable { - private readonly storageRoot: URI; + private storageRoot: URI; private readonly previousEmptyWindowStorageRoot: URI | undefined; private readonly transferredSessionStorageRoot: URI; @@ -55,6 +56,7 @@ export class ChatSessionStore extends Disposable { @ILifecycleService private readonly lifecycleService: ILifecycleService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, ) { super(); @@ -71,6 +73,12 @@ export class ChatSessionStore extends Disposable { this.transferredSessionStorageRoot = joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'transferredChatSessions'); + // Listen to workspace transitions to migrate chat sessions + this._register(this.workspaceEditingService.onDidEnterWorkspace(event => { + const transitionPromise = this.storeQueue.queue(() => this.handleWorkspaceTransition(event.oldWorkspace, event.newWorkspace)); + event.join(transitionPromise); + })); + this._register(this.lifecycleService.onWillShutdown(e => { this.shuttingDown = true; if (!this.storeTask) { @@ -84,6 +92,91 @@ export class ChatSessionStore extends Disposable { })); } + private async handleWorkspaceTransition(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const wasEmptyWindow = isEmptyWorkspaceIdentifier(oldWorkspace); + const isNewWorkspaceEmpty = isEmptyWorkspaceIdentifier(newWorkspace); + const oldWorkspaceId = oldWorkspace.id; + const newWorkspaceId = newWorkspace.id; + + this.logService.info(`ChatSessionStore: Workspace transition from ${oldWorkspaceId} to ${newWorkspaceId}`); + + // Determine the old storage location based on the old workspace + const oldStorageRoot = wasEmptyWindow ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, oldWorkspaceId, 'chatSessions'); + + // Determine the new storage location based on the new workspace + const newStorageRoot = isNewWorkspaceEmpty ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, newWorkspaceId, 'chatSessions'); + + // If the storage roots are identical, there is nothing to migrate + if (oldStorageRoot.toString() === newStorageRoot.toString()) { + this.storageRoot = newStorageRoot; + return; + } + + // Update storage root for the new workspace + this.storageRoot = newStorageRoot; + + // Migrate session files from old to new location + await this.migrateSessionsToNewWorkspace(oldStorageRoot, wasEmptyWindow, isNewWorkspaceEmpty); + } + + private async migrateSessionsToNewWorkspace(oldStorageRoot: URI, wasEmptyWindow: boolean, isNewWorkspaceEmpty: boolean): Promise { + try { + // Check if old storage location exists + const oldStorageExists = await this.fileService.exists(oldStorageRoot); + if (!oldStorageExists) { + this.logService.info(`ChatSessionStore: Old storage location does not exist, skipping migration`); + return; + } + + // Read all session files from old location + const oldDirectory = await this.fileService.resolve(oldStorageRoot); + if (!oldDirectory.children) { + this.logService.info(`ChatSessionStore: No children in old storage location, skipping migration`); + return; + } + + this.logService.info(`ChatSessionStore: Found ${oldDirectory.children.length} files in old storage location`); + + // Copy each file to the new location + let migratedCount = 0; + for (const child of oldDirectory.children) { + if (!child.isDirectory && (child.name.endsWith('.json') || child.name.endsWith('.jsonl'))) { + const oldFilePath = child.resource; + const newFilePath = joinPath(this.storageRoot, child.name); + + try { + await this.fileService.copy(oldFilePath, newFilePath, false); + migratedCount++; + } catch (e) { + if (toFileOperationResult(e) === FileOperationResult.FILE_MOVE_CONFLICT) { + // File already exists at target - skip as a no-op + this.logService.trace(`ChatSessionStore: Session file ${child.name} already exists at target, skipping`); + } else { + this.reportError('sessionMigration', `Error migrating chat session file ${child.name}`, e); + } + } + } + } + + this.logService.info(`ChatSessionStore: Copied ${migratedCount} chat session files from ${wasEmptyWindow ? 'empty window' : oldStorageRoot.toString()} to ${isNewWorkspaceEmpty ? 'empty window' : this.storageRoot.toString()} (originals preserved at old location)`); + + // Clear the index cache and flush it to the new storage scope + this.indexCache = undefined; + try { + await this.flushIndex(); + } catch (e) { + this.reportError('migrateWorkspace', 'Error flushing chat session index after workspace migration', e); + } + + } catch (e) { + this.reportError('migrateWorkspace', 'Error migrating chat sessions to new workspace', e); + } + } + async storeSessions(sessions: ChatModel[]): Promise { if (this.shuttingDown) { // Don't start this task if we missed the chance to block shutdown diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 5f57af4bf8d..ee806bff9f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { waitForState } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -25,6 +26,7 @@ import { IWorkbenchAssignmentService } from '../../../../../services/assignment/ import { NullWorkbenchAssignmentService } from '../../../../../services/assignment/test/common/nullAssignmentService.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { TestWorkerService } from '../../../../inlineChat/test/browser/testWorkerService.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; @@ -96,6 +98,9 @@ suite('ChatEditingService', function () { return Disposable.None; } }); + collection.set(IWorkspaceEditingService, new class extends mock() { + override readonly onDidEnterWorkspace = Event.None; + }); collection.set(INotebookService, new class extends mock() { override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined { return undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 6ea83f12ce5..601e1389741 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -32,6 +32,7 @@ import { NullWorkbenchAssignmentService } from '../../../../../services/assignme import { IExtensionService, nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { InMemoryTestFileService, mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; @@ -174,6 +175,7 @@ suite('ChatService', () => { instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); + instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); instantiationService.stub(IChatEditingService, new class extends mock() { override startOrContinueGlobalEditingSession(): IChatEditingSession { return { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts index cce617aabe1..081a235b906 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; @@ -15,9 +17,10 @@ import { IStorageService } from '../../../../../../platform/storage/common/stora import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IUserDataProfilesService, toUserDataProfile } from '../../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { IWorkspaceContextService, WorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, IWorkspaceContextService, WorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { TestWorkspace, Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { InMemoryTestFileService, TestContextService, TestLifecycleService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatModel, ISerializableChatData3 } from '../../../common/model/chatModel.js'; import { ChatSessionStore, IChatTransfer } from '../../../common/model/chatSessionStore.js'; @@ -40,10 +43,27 @@ function createMockChatModel(sessionResource: URI, options?: { customTitle?: str return model as unknown as ChatModel; } +class MockWorkspaceEditingService extends Disposable implements Partial { + private readonly _onDidEnterWorkspace = this._register(new Emitter()); + readonly onDidEnterWorkspace = this._onDidEnterWorkspace.event; + + fireWorkspaceTransition(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const promises: Promise[] = []; + const event: IDidEnterWorkspaceEvent = { + oldWorkspace, + newWorkspace, + join: (promise: Promise) => promises.push(promise) + }; + this._onDidEnterWorkspace.fire(event); + return Promise.all(promises).then(() => { }); + } +} + suite('ChatSessionStore', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; + let mockWorkspaceEditingService: MockWorkspaceEditingService; function createChatSessionStore(isEmptyWindow: boolean = false): ChatSessionStore { const workspace = isEmptyWindow ? new Workspace('empty-window-id', []) : TestWorkspace; @@ -61,6 +81,8 @@ suite('ChatSessionStore', () => { instantiationService.stub(ILifecycleService, testDisposables.add(new TestLifecycleService())); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(IConfigurationService, new TestConfigurationService()); + mockWorkspaceEditingService = testDisposables.add(new MockWorkspaceEditingService()); + instantiationService.stub(IWorkspaceEditingService, mockWorkspaceEditingService as unknown as IWorkspaceEditingService); }); test('hasSessions returns false when no sessions exist', () => { @@ -374,4 +396,70 @@ suite('ChatSessionStore', () => { assert.strictEqual(result.toString(), session2Resource.toString()); }); }); + + suite('workspace migration', () => { + test('migration is triggered when onDidEnterWorkspace fires', async () => { + const fileService = instantiationService.get(IFileService) as InMemoryTestFileService; + + // Create store with empty window + const store = createChatSessionStore(true); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + // Store a session in empty window + await store.storeSessions([model]); + assert.strictEqual(store.hasSessions(), true); + + // Get the file path for the session in empty window storage + const emptyWindowStorageRoot = store.getChatStorageFolder(); + const sessionFile = URI.joinPath(emptyWindowStorageRoot, 'session-1.json'); + const fileExists = await fileService.exists(sessionFile); + assert.strictEqual(fileExists, true, 'Session file should exist in empty window storage'); + + // Simulate workspace transition via the onDidEnterWorkspace event + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'empty-window-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: TestWorkspace.id, uri: URI.file('/test/folder') }; + + // Fire the workspace transition event - migration happens synchronously via join() + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + // Verify file was copied to new location + const newStorageRoot = store.getChatStorageFolder(); + const migratedSessionFile = URI.joinPath(newStorageRoot, 'session-1.json'); + const migratedFileExists = await fileService.exists(migratedSessionFile); + assert.strictEqual(migratedFileExists, true, 'Session file should be migrated to workspace storage'); + }); + + test('migration handles non-existent old storage location gracefully', async () => { + // Create store with a workspace + const store = createChatSessionStore(false); + + // Simulate workspace transition from a non-existent workspace + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'non-existent-workspace-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: 'new-workspace-id' }; + + // Fire the workspace transition event - should not crash + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + // Store should work normally + assert.strictEqual(store.hasSessions(), false); + }); + + test('storage root is updated after workspace transition', async () => { + // Create store with empty window + const store = createChatSessionStore(true); + + const initialStorageRoot = store.getChatStorageFolder(); + assert.ok(initialStorageRoot.path.includes('emptyWindowChatSessions'), 'Initial storage should be empty window location'); + + // Simulate workspace transition - use proper identifier types + // Empty workspace only has 'id', single folder has 'uri' property too + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'empty-window-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: 'new-workspace-id', uri: URI.file('/test/folder') }; + + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + const newStorageRoot = store.getChatStorageFolder(); + assert.ok(newStorageRoot.path.includes('new-workspace-id'), 'Storage root should be updated to new workspace location'); + }); + }); }); From 936dee9d943a558ec0742ebae25d027b54ca6f10 Mon Sep 17 00:00:00 2001 From: digitarald Date: Tue, 10 Feb 2026 09:42:24 -0800 Subject: [PATCH 40/64] Moved planAgent model config to core for picker UI --- .../contrib/chat/browser/chat.contribution.ts | 9 ++ .../chat/browser/planAgentDefaultModel.ts | 107 ++++++++++++++++++ .../contrib/chat/common/constants.ts | 1 + 3 files changed, 117 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 1c71ce192a5..91706e2e422 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -143,6 +143,7 @@ import { ChatRepoInfoContribution } from './chatRepoInfo.js'; import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; import { ChatTipService, IChatTipService } from './chatTipService.js'; import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; +import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -614,6 +615,14 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.PlanAgentDefaultModel]: { + type: 'string', + description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), + default: '', + enum: PlanAgentDefaultModel.modelIds, + enumItemLabels: PlanAgentDefaultModel.modelLabels, + markdownEnumDescriptions: PlanAgentDefaultModel.modelDescriptions + }, [ChatConfiguration.RequestQueueingEnabled]: { type: 'boolean', description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), diff --git a/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts new file mode 100644 index 00000000000..37c49f0774f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { ChatConfiguration } from '../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../common/widget/input/modelPickerWidget.js'; + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + +export class PlanAgentDefaultModel extends Disposable { + static readonly ID = 'workbench.contrib.planAgentDefaultModel'; + static readonly configName = ChatConfiguration.PlanAgentDefaultModel; + + static modelIds: string[] = ['']; + static modelLabels: string[] = [localize('defaultModel', 'Auto (Vendor Default)')]; + static modelDescriptions: string[] = [localize('defaultModelDescription', "Use the vendor's default model")]; + + constructor( + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._register(languageModelsService.onDidChangeLanguageModels(() => this._updateModelValues())); + this._updateModelValues(); + } + + private _updateModelValues(): void { + try { + // Clear arrays + PlanAgentDefaultModel.modelIds.length = 0; + PlanAgentDefaultModel.modelLabels.length = 0; + PlanAgentDefaultModel.modelDescriptions.length = 0; + + // Add default/empty option + PlanAgentDefaultModel.modelIds.push(''); + PlanAgentDefaultModel.modelLabels.push(localize('defaultModel', 'Auto (Vendor Default)')); + PlanAgentDefaultModel.modelDescriptions.push(localize('defaultModelDescription', "Use the vendor's default model")); + + const models: { identifier: string; metadata: ILanguageModelChatMetadata }[] = []; + const modelIds = this.languageModelsService.getLanguageModelIds(); + + for (const modelId of modelIds) { + try { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + if (metadata) { + models.push({ identifier: modelId, metadata }); + } else { + this.logService.warn(`[PlanAgentDefaultModel] No metadata found for model ID: ${modelId}`); + } + } catch (e) { + this.logService.error(`[PlanAgentDefaultModel] Error looking up model ${modelId}:`, e); + } + } + + const supportedModels = models.filter(model => { + if (!model.metadata?.isUserSelectable) { + return false; + } + if (!model.metadata.capabilities?.toolCalling) { + return false; + } + return true; + }); + + supportedModels.sort((a, b) => { + const aCategory = a.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY; + const bCategory = b.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY; + + if (aCategory.order !== bCategory.order) { + return aCategory.order - bCategory.order; + } + + return a.metadata.name.localeCompare(b.metadata.name); + }); + + for (const model of supportedModels) { + try { + const qualifiedName = `${model.metadata.name} (${model.metadata.vendor})`; + PlanAgentDefaultModel.modelIds.push(qualifiedName); + PlanAgentDefaultModel.modelLabels.push(model.metadata.name); + PlanAgentDefaultModel.modelDescriptions.push(model.metadata.tooltip ?? model.metadata.detail ?? ''); + } catch (e) { + this.logService.error(`[PlanAgentDefaultModel] Error adding model ${model.metadata.name}:`, e); + } + } + + configurationRegistry.notifyConfigurationSchemaUpdated({ + id: 'chatSidebar', + properties: { + [ChatConfiguration.PlanAgentDefaultModel]: {} + } + }); + } catch (e) { + this.logService.error('[PlanAgentDefaultModel] Error updating model values:', e); + } + } +} + +registerWorkbenchContribution2(PlanAgentDefaultModel.ID, PlanAgentDefaultModel, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8270d7cf9e4..a5eec8a6ad8 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,6 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', AgentEnabled = 'chat.agent.enabled', + PlanAgentDefaultModel = 'chat.planAgent.defaultModel', RequestQueueingEnabled = 'chat.requestQueuing.enabled', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', AgentStatusEnabled = 'chat.agentsControl.enabled', From 97c52349b904f1d73d2be58ce4033f79ddd75e39 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Tue, 10 Feb 2026 09:47:27 -0800 Subject: [PATCH 41/64] new session menu entry (#294164) --- .../services/actions/common/menusExtensionPoint.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 0b7630309b9..ad7aa09f9ea 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -514,6 +514,12 @@ const apiMenus: IAPIMenu[] = [ description: localize('menus.chatContextUsageActions', "Actions in the chat context usage details popup."), proposed: 'chatParticipantAdditions' }, + { + key: 'chat/newSession', + id: MenuId.ChatNewMenu, + description: localize('menus.chatNewSession', "The Chat new session menu."), + proposed: 'chatSessionsProvider' + }, ]; namespace schema { From 7aa320fdb15cefd514ea2326c27953693dcd7ada Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:48:43 -0800 Subject: [PATCH 42/64] send images in folders as attachments (#294023) * send images in folders as attachments * fix tests * Update src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address some comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chatAttachmentResolveService.ts | 40 ++++ .../contrib/chat/browser/widget/chatWidget.ts | 28 ++- .../chat/common/chatService/chatService.ts | 1 + .../common/chatService/chatServiceImpl.ts | 6 + .../chatAttachmentResolveService.test.ts | 202 ++++++++++++++++++ 5 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts index 60e6559ffc3..e5fb7e719a9 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts @@ -47,6 +47,7 @@ export interface IChatAttachmentResolveService { resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[]; resolveNotebookOutputAttachContext(data: NotebookCellOutputTransferData): IChatRequestVariableEntry[]; resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[]; + resolveDirectoryImages(directoryUri: URI): Promise; } export class ChatAttachmentResolveService implements IChatAttachmentResolveService { @@ -277,6 +278,45 @@ export class ChatAttachmentResolveService implements IChatAttachmentResolveServi return []; } + // --- DIRECTORIES --- + + public async resolveDirectoryImages(directoryUri: URI): Promise { + const imageEntries: IChatRequestVariableEntry[] = []; + await this._collectDirectoryImages(directoryUri, imageEntries); + return imageEntries; + } + + private async _collectDirectoryImages(directoryUri: URI, results: IChatRequestVariableEntry[]): Promise { + let stat; + try { + stat = await this.fileService.resolve(directoryUri); + } catch { + return; + } + + if (!stat.children) { + return; + } + + const childPromises: Promise[] = []; + + for (const child of stat.children) { + if (child.isDirectory && !child.isSymbolicLink) { + childPromises.push(this._collectDirectoryImages(child.resource, results)); + } else if (child.isFile && !child.isSymbolicLink && SUPPORTED_IMAGE_EXTENSIONS_REGEX.test(child.resource.path)) { + childPromises.push( + this.resolveImageEditorAttachContext(child.resource).then(entry => { + if (entry) { + results.push(entry); + } + }).catch(() => { /* skip unreadable images */ }) + ); + } + } + + await Promise.all(childPromises); + } + // --- SOURCE CONTROL --- public resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[] { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 85111d9787d..8fa0fbb402f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -71,6 +71,7 @@ import { IPromptsService } from '../../common/promptSyntax/service/promptsServic import { handleModeSwitch } from '../actions/chatActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewModelChangeEvent, IChatWidgetViewOptions, isIChatResourceViewContext, isIChatViewViewContext } from '../chat.js'; import { ChatAttachmentModel } from '../attachments/chatAttachmentModel.js'; +import { IChatAttachmentResolveService } from '../attachments/chatAttachmentResolveService.js'; import { ChatSuggestNextWidget } from './chatContentParts/chatSuggestNextWidget.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from './input/chatInputPart.js'; import { IChatListItemTemplate } from './chatListRenderer.js'; @@ -365,7 +366,8 @@ export class ChatWidget extends Disposable implements IChatWidget { @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatTodoListService private readonly chatTodoListService: IChatTodoListService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, - @ILifecycleService private readonly lifecycleService: ILifecycleService + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, ) { super(); @@ -2210,6 +2212,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } } } + // Expand directory attachments: extract images as binary entries + const resolvedImageVariables = await this._resolveDirectoryImageAttachments(requestInputs.attachedContext.asArray()); + if (this.viewModel.sessionResource && !options.queue) { // todo@connor4312: move chatAccessibilityService.acceptRequest to a refcount model to handle queue messages this.chatAccessibilityService.acceptRequest(this._viewModel!.sessionResource); @@ -2221,6 +2226,7 @@ export class ChatWidget extends Disposable implements IChatWidget { locationData: this._location.resolveData?.(), parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }, attachedContext: requestInputs.attachedContext.asArray(), + resolvedVariables: resolvedImageVariables, noCommandDetection: options?.noCommandDetection, ...this.getModeRequestOptions(), modeInfo: this.input.currentModeInfo, @@ -2268,6 +2274,26 @@ export class ChatWidget extends Disposable implements IChatWidget { return sent.data.responseCreatedPromise; } + // Resolve images from directory attachments to send as additional variables. + private async _resolveDirectoryImageAttachments(attachments: IChatRequestVariableEntry[]): Promise { + const imagePromises: Promise[] = []; + + for (const attachment of attachments) { + if (attachment.kind === 'directory' && URI.isUri(attachment.value)) { + imagePromises.push( + this.chatAttachmentResolveService.resolveDirectoryImages(attachment.value) + ); + } + } + + if (imagePromises.length === 0) { + return []; + } + + const resolved = await Promise.all(imagePromises); + return resolved.flat(); + } + private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise { if (options.queue) { return true; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 70c534c3f07..657c6d6ac1e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1300,6 +1300,7 @@ export interface IChatSendRequestOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any rejectedConfirmationData?: any[]; attachedContext?: IChatRequestVariableEntry[]; + resolvedVariables?: IChatRequestVariableEntry[]; /** The target agent ID can be specified with this property instead of using @ in 'message' */ agentId?: string; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 69054bd688a..838008c642b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -952,6 +952,12 @@ export class ChatService extends Disposable implements IChatService { variableData = { variables: this.prepareContext(request.attachedContext) }; model.updateRequest(request, variableData); + // Merge resolved variables (e.g. images from directories) for the + // agent request only - they are not stored on the request model. + if (options?.resolvedVariables?.length) { + variableData = { variables: [...variableData.variables, ...options.resolvedVariables] }; + } + const promptTextResult = getPromptText(request.message); variableData = updateRanges(variableData, promptTextResult.diff); // TODO bit of a hack message = promptTextResult.message; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts new file mode 100644 index 00000000000..0105ff47128 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ChatAttachmentResolveService } from '../../browser/attachments/chatAttachmentResolveService.js'; +import { createFileStat } from '../../../../test/common/workbenchTestServices.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +suite('ChatAttachmentResolveService', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let service: ChatAttachmentResolveService; + + /** + * Map from directory URI string to children, simulating a file tree. + * Populated per-test to control the mock directory structure. + */ + let directoryTree: Map; + + /** + * Set of file URI strings that should be treated as valid images + * by the mocked resolveImageEditorAttachContext. + */ + let imageFileUris: Set; + + setup(() => { + instantiationService = testDisposables.add(new TestInstantiationService()); + directoryTree = new Map(); + imageFileUris = new Set(); + + // Stub IFileService with resolve() that uses the directoryTree map + instantiationService.stub(IFileService, { + resolve: async (resource: URI): Promise => { + const children = directoryTree.get(resource.toString()); + if (children !== undefined) { + return createFileStat(resource, false, false, true, false, children); + } + // Treat as a file + return createFileStat(resource, false, true, false); + } + }); + + instantiationService.stub(IEditorService, {}); + instantiationService.stub(ITextModelService, {}); + instantiationService.stub(IExtensionService, {}); + instantiationService.stub(IDialogService, {}); + + service = instantiationService.createInstance(ChatAttachmentResolveService); + + // Override resolveImageEditorAttachContext to avoid DOM dependencies (canvas, Image, etc.) + // and return a predictable image entry for files in the imageFileUris set. + service.resolveImageEditorAttachContext = async (resource: URI): Promise => { + if (imageFileUris.has(resource.toString())) { + return { + id: resource.toString(), + name: resource.path.split('/').pop()!, + value: new Uint8Array([1, 2, 3]), + kind: 'image', + }; + } + return undefined; + }; + }); + + test('returns empty array for empty directory', async () => { + const dirUri = URI.file('/test/empty-dir'); + directoryTree.set(dirUri.toString(), []); + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('returns image entries for image files in directory', async () => { + const dirUri = URI.file('/test/images-dir'); + const pngUri = URI.file('/test/images-dir/photo.png'); + const jpgUri = URI.file('/test/images-dir/photo.jpg'); + const txtUri = URI.file('/test/images-dir/readme.txt'); + + directoryTree.set(dirUri.toString(), [ + { resource: pngUri, isFile: true, isDirectory: false }, + { resource: jpgUri, isFile: true, isDirectory: false }, + { resource: txtUri, isFile: true, isDirectory: false }, + ]); + imageFileUris.add(pngUri.toString()); + imageFileUris.add(jpgUri.toString()); + + const result = await service.resolveDirectoryImages(dirUri); + assert.strictEqual(result.length, 2); + assert.ok(result.every(e => e.kind === 'image')); + const names = result.map(e => e.name).sort(); + assert.deepStrictEqual(names, ['photo.jpg', 'photo.png']); + }); + + test('ignores non-image files', async () => { + const dirUri = URI.file('/test/text-dir'); + const txtUri = URI.file('/test/text-dir/file.txt'); + const tsUri = URI.file('/test/text-dir/index.ts'); + + directoryTree.set(dirUri.toString(), [ + { resource: txtUri, isFile: true, isDirectory: false }, + { resource: tsUri, isFile: true, isDirectory: false }, + ]); + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('recursively discovers images in subdirectories', async () => { + const rootUri = URI.file('/test/root'); + const subDirUri = URI.file('/test/root/subdir'); + const deepDirUri = URI.file('/test/root/subdir/deep'); + + const rootPng = URI.file('/test/root/logo.png'); + const subPng = URI.file('/test/root/subdir/banner.webp'); + const deepJpg = URI.file('/test/root/subdir/deep/photo.jpeg'); + const deepTxt = URI.file('/test/root/subdir/deep/notes.txt'); + + directoryTree.set(rootUri.toString(), [ + { resource: rootPng, isFile: true, isDirectory: false }, + { resource: subDirUri, isFile: false, isDirectory: true }, + ]); + directoryTree.set(subDirUri.toString(), [ + { resource: subPng, isFile: true, isDirectory: false }, + { resource: deepDirUri, isFile: false, isDirectory: true }, + ]); + directoryTree.set(deepDirUri.toString(), [ + { resource: deepJpg, isFile: true, isDirectory: false }, + { resource: deepTxt, isFile: true, isDirectory: false }, + ]); + + imageFileUris.add(rootPng.toString()); + imageFileUris.add(subPng.toString()); + imageFileUris.add(deepJpg.toString()); + + const result = await service.resolveDirectoryImages(rootUri); + assert.strictEqual(result.length, 3); + assert.ok(result.every(e => e.kind === 'image')); + const names = result.map(e => e.name).sort(); + assert.deepStrictEqual(names, ['banner.webp', 'logo.png', 'photo.jpeg']); + }); + + test('handles unreadable directory gracefully', async () => { + const dirUri = URI.file('/test/unreadable'); + // Override resolve to throw for this URI + instantiationService.stub(IFileService, { + resolve: async (resource: URI): Promise => { + if (resource.toString() === dirUri.toString()) { + throw new Error('Permission denied'); + } + return createFileStat(resource, false, true, false); + } + }); + // Re-create service with the new stub + service = instantiationService.createInstance(ChatAttachmentResolveService); + service.resolveImageEditorAttachContext = async (resource: URI): Promise => { + if (imageFileUris.has(resource.toString())) { + return { + id: resource.toString(), + name: resource.path.split('/').pop()!, + value: new Uint8Array([1, 2, 3]), + kind: 'image', + }; + } + return undefined; + }; + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('handles mixed directory with images and non-images', async () => { + const dirUri = URI.file('/test/mixed'); + const gifUri = URI.file('/test/mixed/animation.gif'); + const jsUri = URI.file('/test/mixed/script.js'); + const bmpUri = URI.file('/test/mixed/icon.bmp'); + + directoryTree.set(dirUri.toString(), [ + { resource: gifUri, isFile: true, isDirectory: false }, + { resource: jsUri, isFile: true, isDirectory: false }, + { resource: bmpUri, isFile: true, isDirectory: false }, + ]); + imageFileUris.add(gifUri.toString()); + imageFileUris.add(bmpUri.toString()); + // bmp is NOT in CHAT_ATTACHABLE_IMAGE_MIME_TYPES (only png, jpg, jpeg, gif, webp) + // so it should be skipped by the regex even though it would resolve successfully + + const result = await service.resolveDirectoryImages(dirUri); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'animation.gif'); + }); +}); From fe159c64465123374dd9c029ee5878e237ccfecc Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:50:05 -0800 Subject: [PATCH 43/64] Fix local sessions not refreshing properly --- .../api/browser/mainThreadChatSessions.ts | 12 +- .../api/common/extHostChatSessions.ts | 12 +- .../agentSessions.contribution.ts | 2 +- ...der.ts => localAgentSessionsController.ts} | 20 ++-- ...s => localAgentSessionsController.test.ts} | 112 +++++++++--------- 5 files changed, 76 insertions(+), 82 deletions(-) rename src/vs/workbench/contrib/chat/browser/agentSessions/{localAgentSessionsProvider.ts => localAgentSessionsController.ts} (93%) rename src/vs/workbench/contrib/chat/test/browser/agentSessions/{localAgentSessionsProvider.test.ts => localAgentSessionsController.test.ts} (89%) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 797db7cff68..ff9ea448f4a 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -5,7 +5,7 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; @@ -326,7 +326,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes private readonly _handle: number; private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); - readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; constructor( proxy: ExtHostChatSessionsShape, @@ -410,17 +410,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } $registerChatSessionItemController(handle: number, chatSessionType: string): void { - // Register the controller handle - items will be pushed via $setChatSessionItems const disposables = new DisposableStore(); - const controller = new MainThreadChatSessionItemController(this._proxy, handle); - disposables.add(controller); + const controller = disposables.add(new MainThreadChatSessionItemController(this._proxy, handle)); disposables.add(this._chatSessionsService.registerChatSessionItemController(chatSessionType, controller)); this._itemControllerRegistrations.set(handle, { - dispose: () => disposables.dispose(), chatSessionType, controller, + dispose: () => disposables.dispose(), }); disposables.add(this._chatSessionsService.registerChatModelChangeListeners( @@ -437,7 +435,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat async $setChatSessionItems(handle: number, items: Dto[]): Promise { const registration = this._itemControllerRegistrations.get(handle); if (!registration) { - this._logService.warn(`No controller registered for handle ${handle}`); + this._logService.warn(`No chat session controller registered for handle ${handle}`); return; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 14b71d20dbb..46a7230a092 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -324,7 +324,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - // Helper to fetch and push items to main thread + const collection = new ChatSessionItemCollectionImpl(() => { + // Noop for providers + }); + + // Helper to push items to main thread const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { collection.replace(items); const convertedItems: IChatSessionItem[] = []; @@ -335,15 +339,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio void this._proxy.$setChatSessionItems(handle, convertedItems); }; - const collection = new ChatSessionItemCollectionImpl(() => { - // Noop for providers - }); - const controller: vscode.ChatSessionItemController = { id: chatSessionType, items: collection, createChatSessionItem: (_resource: vscode.Uri, _label: string) => { - throw new Error('not implemented'); + throw new Error('Not implemented for providers'); }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, dispose: () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 3824399027b..95a563c19f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -13,7 +13,7 @@ import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../.. import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions.js'; import { IAgentSessionsService, AgentSessionsService } from './agentSessionsService.js'; -import { LocalAgentsSessionsController } from './localAgentSessionsProvider.js'; +import { LocalAgentsSessionsController } from './localAgentSessionsController.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction } from './agentSessionsActions.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts similarity index 93% rename from src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index bbbb67e2cdb..18d0bcb1c29 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; export class LocalAgentsSessionsController extends Disposable implements IChatSessionItemController, IWorkbenchContribution { - static readonly ID = 'workbench.contrib.localAgentsSessionsProvider'; + static readonly ID = 'workbench.contrib.localAgentsSessionsController'; readonly chatSessionType = localChatSessionType; @@ -51,22 +51,18 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe } private registerListeners(): void { - this._register(this.chatSessionsService.registerChatModelChangeListeners( - this.chatService, - Schemas.vscodeLocalChatSession, - () => this._onDidChangeChatSessionItems.fire() - )); + const refreshItems = async () => { + this._onDidChangeChatSessionItems.fire(); + await this.refresh(CancellationToken.None); + this._onDidChangeChatSessionItems.fire(); + }; - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { - if (chatSessionType === this.chatSessionType) { - this._onDidChange.fire(); - } - })); + this._register(this.chatSessionsService.registerChatModelChangeListeners(this.chatService, Schemas.vscodeLocalChatSession, refreshItems)); this._register(this.chatService.onDidDisposeSession(e => { const session = e.sessionResource.filter(resource => getChatSessionType(resource) === this.chatSessionType); if (session.length > 0) { - this._onDidChangeChatSessionItems.fire(); + refreshItems(); } })); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts similarity index 89% rename from src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts rename to src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts index 7bc70307a08..3cbc1cccf03 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts @@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsProvider.js'; +import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsController.js'; import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ChatRequestQueueKind, IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; @@ -300,39 +300,39 @@ suite('LocalAgentsSessionsController', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createProvider(): LocalAgentsSessionsController { + function createController(): LocalAgentsSessionsController { return disposables.add(instantiationService.createInstance(LocalAgentsSessionsController)); } test('should have correct session type', () => { - const provider = createProvider(); - assert.strictEqual(provider.chatSessionType, localChatSessionType); + const controller = createController(); + assert.strictEqual(controller.chatSessionType, localChatSessionType); }); test('should register itself with chat sessions service', async () => { - const provider = createProvider(); + const controller = createController(); - const providerResults = await mockChatSessionsService.getChatSessionItems(undefined, CancellationToken.None); - assert.strictEqual(providerResults.length, 1); - assert.strictEqual(providerResults[0].chatSessionType, provider.chatSessionType); + const controllerResults = await mockChatSessionsService.getChatSessionItems(undefined, CancellationToken.None); + assert.strictEqual(controllerResults.length, 1); + assert.strictEqual(controllerResults[0].chatSessionType, controller.chatSessionType); }); test('should provide empty sessions when no live or history sessions', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); mockChatService.setLiveSessionItems([]); mockChatService.setHistorySessionItems([]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 0); }); }); test('should provide live session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('test-session'); const mockModel = createMockChatModel({ @@ -351,8 +351,8 @@ suite('LocalAgentsSessionsController', () => { lastResponseState: ResponseModelState.Complete }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Test Session'); assert.strictEqual(sessions[0].resource.toString(), sessionResource.toString()); @@ -361,7 +361,7 @@ suite('LocalAgentsSessionsController', () => { test('should provide history session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-session'); @@ -375,8 +375,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'History Session'); }); @@ -384,7 +384,7 @@ suite('LocalAgentsSessionsController', () => { test('should not duplicate sessions in history and live', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('duplicate-session'); const mockModel = createMockChatModel({ @@ -410,8 +410,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Live Session'); }); @@ -420,7 +420,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Status', () => { test('should return InProgress status when request in progress', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('in-progress-session'); const mockModel = createMockChatModel({ @@ -439,8 +439,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.InProgress); }); @@ -448,7 +448,7 @@ suite('LocalAgentsSessionsController', () => { test('should return Completed status when last response is complete', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('completed-session'); const mockModel = createMockChatModel({ @@ -470,8 +470,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -479,7 +479,7 @@ suite('LocalAgentsSessionsController', () => { test('should return Success status when last response was canceled', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('canceled-session'); const mockModel = createMockChatModel({ @@ -500,8 +500,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -509,7 +509,7 @@ suite('LocalAgentsSessionsController', () => { test('should return Failed status when last response has error', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('error-session'); const mockModel = createMockChatModel({ @@ -530,8 +530,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); }); @@ -541,7 +541,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Statistics', () => { test('should return statistics for sessions with modified entries', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('stats-session'); const mockModel = createMockChatModel({ @@ -580,8 +580,8 @@ suite('LocalAgentsSessionsController', () => { } }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.ok(sessions[0].changes); const changes = sessions[0].changes as { files: number; insertions: number; deletions: number }; @@ -593,7 +593,7 @@ suite('LocalAgentsSessionsController', () => { test('should not return statistics for sessions without modified entries', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('no-stats-session'); const mockModel = createMockChatModel({ @@ -621,8 +621,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].changes, undefined); }); @@ -632,7 +632,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Timing', () => { test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('timing-session'); const modelTimestamp = Date.now() - 5000; @@ -652,8 +652,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ created: modelTimestamp }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); @@ -661,7 +661,7 @@ suite('LocalAgentsSessionsController', () => { test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-timing'); const lastMessageDate = Date.now() - 10000; @@ -676,8 +676,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ created: lastMessageDate }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); @@ -685,7 +685,7 @@ suite('LocalAgentsSessionsController', () => { test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('endtime-session'); const completedAt = Date.now() - 1000; @@ -706,8 +706,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ lastRequestEnded: completedAt }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); @@ -717,7 +717,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Icon', () => { test('should use Codicon.chatSparkle as icon', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('icon-session'); const mockModel = createMockChatModel({ @@ -735,8 +735,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); }); @@ -746,7 +746,7 @@ suite('LocalAgentsSessionsController', () => { suite('Events', () => { test('should fire onDidChangeChatSessionItems when model progress changes', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('progress-session'); const mockModel = createMockChatModel({ @@ -759,7 +759,7 @@ suite('LocalAgentsSessionsController', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -772,7 +772,7 @@ suite('LocalAgentsSessionsController', () => { test('should fire onDidChangeChatSessionItems when model request status changes', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('status-change-session'); const mockModel = createMockChatModel({ @@ -785,7 +785,7 @@ suite('LocalAgentsSessionsController', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -798,7 +798,7 @@ suite('LocalAgentsSessionsController', () => { test('should clean up model listeners when model is removed via chatModels observable', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('cleanup-session'); const mockModel = createMockChatModel({ @@ -816,7 +816,7 @@ suite('LocalAgentsSessionsController', () => { // The onDidChangeChatSessionItems from registerModelListeners cleanup should fire once // but after that, title changes should NOT fire onDidChangeChatSessionItems let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); From eb0e8808c5ea5a3d328a8c28af25484dd63b53a9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 11:52:41 -0600 Subject: [PATCH 44/64] make auto run task notification more specific (#294161) --- src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts index 2b029d727aa..ef940211d69 100644 --- a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts +++ b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts @@ -180,12 +180,12 @@ export class RunAutomaticTasks extends Disposable implements IWorkbenchContribut private _showPrompt(notificationService: INotificationService, storageService: IStorageService, openerService: IOpenerService, configurationService: IConfigurationService, taskNames: string[], locations: Map): Promise { return new Promise(resolve => { notificationService.prompt(Severity.Info, nls.localize('tasks.run.allowAutomatic', - "This workspace has tasks ({0}) defined ({1}) that run automatically when you open this workspace. Do you allow automatic tasks to run when you open this workspace?", + "This workspace has tasks ({0}) defined ({1}) that run automatically when you open this workspace. Do you want to allow automatic tasks to run in all trusted workspaces?", taskNames.join(', '), Array.from(locations.keys()).join(', ') ), [{ - label: nls.localize('allow', "Allow and Run"), + label: nls.localize('allow', "Allow"), run: () => { resolve(true); configurationService.updateValue(ALLOW_AUTOMATIC_TASKS, 'on', ConfigurationTarget.USER); From d4ee4a532a2663a0efa23dfdbc0eef2e3aa7ae24 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 10 Feb 2026 19:07:56 +0100 Subject: [PATCH 45/64] fix when to use last fetched data (#294174) --- .../workbench/services/accounts/browser/defaultAccount.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 66b782d7c91..868cfac8c79 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -469,11 +469,11 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], donotUseLastFetchedData: boolean): Promise { try { const accountId = sessions[0].account.id; - const accountPolicyData = !donotUseLastFetchedData && this._policyData?.accountId === accountId ? this._policyData : undefined; + const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; const [entitlementsData, tokenEntitlementsData] = await Promise.all([ this.getEntitlements(sessions), - this.getTokenEntitlements(sessions, accountPolicyData), + this.getTokenEntitlements(sessions, donotUseLastFetchedData ? undefined : accountPolicyData), ]); let isTokenEntitlementsDataFetched = false; @@ -486,7 +486,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid policyData.chat_preview_features_enabled = tokenEntitlementsData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.mcp; if (policyData.mcp) { - const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, accountPolicyData); + const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, donotUseLastFetchedData ? undefined : accountPolicyData); if (!isUndefined(mcpRegistryProvider)) { isMcpRegistryDataFetched = true; policyData.mcpRegistryUrl = mcpRegistryProvider?.url; From 0ad4e82a024aa97d085e520d3a81710d5ed9b014 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:22:04 -0800 Subject: [PATCH 46/64] Redact keys in trace logs Fixes #294158 --- src/vs/platform/terminal/node/ptyService.ts | 23 ++++- .../terminal/node/terminalEnvironment.ts | 53 +++++++++++ .../platform/terminal/node/terminalProcess.ts | 5 +- .../test/node/terminalEnvironment.test.ts | 90 ++++++++++++++++++- 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index a600f7b27d1..826b09f47c8 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -18,7 +18,7 @@ import { escapeNonWindowsPath } from '../common/terminalEnvironment.js'; import type { ISerializeOptions, SerializeAddon as XtermSerializeAddon } from '@xterm/addon-serialize'; import type { Unicode11Addon as XtermUnicode11Addon } from '@xterm/addon-unicode11'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, ISetTerminalLayoutInfoArgs, ITerminalTabLayoutInfoDto } from '../common/terminalProcess.js'; -import { getWindowsBuildNumber } from './terminalEnvironment.js'; +import { getWindowsBuildNumber, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { TerminalProcess } from './terminalProcess.js'; import { localize } from '../../../nls.js'; import { ignoreProcessNames } from './childProcessMonitor.js'; @@ -37,6 +37,24 @@ import { hasKey, isFunction, isNumber, isString } from '../../../base/common/typ type XtermTerminal = pkg.Terminal; const { Terminal: XtermTerminal } = pkg; +/** + * Sanitizes arguments for logging, specifically handling env objects in createProcess calls. + */ +function sanitizeArgsForLogging(fnName: string, args: unknown[]): unknown[] { + // createProcess signature: shellLaunchConfig, cwd, cols, rows, unicodeVersion, env (index 5), executableEnv (index 6), ... + if (fnName === 'createProcess' && args.length > 5) { + const sanitizedArgs = [...args]; + if (args[5] && typeof args[5] === 'object') { + sanitizedArgs[5] = sanitizeEnvForLogging(args[5] as IProcessEnvironment); + } + if (args[6] && typeof args[6] === 'object') { + sanitizedArgs[6] = sanitizeEnvForLogging(args[6] as IProcessEnvironment); + } + return sanitizedArgs; + } + return args; +} + interface ITraceRpcArgs { logService: ILogService; simulatedLatency: number; @@ -50,7 +68,8 @@ export function traceRpc(_target: Object, key: string, descriptor: PropertyDescr const fn = descriptor.value; descriptor[fnKey] = async function (this: TThis, ...args: unknown[]) { if (this.traceRpcArgs.logService.getLevel() === LogLevel.Trace) { - this.traceRpcArgs.logService.trace(`[RPC Request] PtyService#${fn.name}(${args.map(e => JSON.stringify(e)).join(', ')})`); + const sanitizedArgs = sanitizeArgsForLogging(fn.name, args); + this.traceRpcArgs.logService.trace(`[RPC Request] PtyService#${fn.name}(${sanitizedArgs.map(e => JSON.stringify(e)).join(', ')})`); } if (this.traceRpcArgs.simulatedLatency) { await timeout(this.traceRpcArgs.simulatedLatency); diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index 1d150cd6b8e..62a0c53bb88 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -375,3 +375,56 @@ function areZshBashFishLoginArgs(originalArgs: SingleOrMany): boolean { return isString(originalArgs) && shLoginArgs.includes(originalArgs.toLowerCase()) || !isString(originalArgs) && originalArgs.length === 1 && shLoginArgs.includes(originalArgs[0].toLowerCase()); } + +/** + * Patterns that indicate sensitive environment variable names. + */ +const sensitiveEnvVarNames = /^(?:.*_)?(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH|PRIVATE_?KEY|ACCESS_?KEY|CLIENT_?SECRET|APIKEY)(?:_.*)?$/i; + +/** + * Patterns for detecting secret values in environment variables. + */ +const secretValuePatterns = [ + // JWT tokens + /^eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/, + // GitHub tokens + /^gh[psuro]_[a-zA-Z0-9]{36}$/, + /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/, + // Google API keys + /^AIza[A-Za-z0-9_\-]{35}$/, + // Slack tokens + /^xox[pbar]\-[A-Za-z0-9\-]+$/, + // Azure/MS tokens (common patterns) + /^[a-zA-Z0-9]{32,}$/, +]; + +/** + * Sanitizes environment variables for logging by redacting sensitive values. + */ +export function sanitizeEnvForLogging(env: IProcessEnvironment | undefined): IProcessEnvironment | undefined { + if (!env) { + return env; + } + const sanitized: IProcessEnvironment = {}; + for (const key of Object.keys(env)) { + const value = env[key]; + if (value === undefined) { + continue; + } + // Check if the key name suggests a sensitive value + if (sensitiveEnvVarNames.test(key)) { + sanitized[key] = ''; + continue; + } + // Check if the value matches known secret patterns + let isSecret = false; + for (const pattern of secretValuePatterns) { + if (pattern.test(value)) { + isSecret = true; + break; + } + } + sanitized[key] = isSecret ? '' : value; + } + return sanitized; +} diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index aba2699e665..968009e20e6 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -17,7 +17,7 @@ import { ILogService, LogLevel } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { FlowControlConstants, IShellLaunchConfig, ITerminalChildProcess, ITerminalLaunchError, IProcessProperty, IProcessPropertyMap, ProcessPropertyType, TerminalShellType, IProcessReadyEvent, ITerminalProcessOptions, PosixShellType, IProcessReadyWindowsPty, GeneralShellType, ITerminalLaunchResult } from '../common/terminal.js'; import { ChildProcessMonitor } from './childProcessMonitor.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection } from './terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { WindowsShellHelper } from './windowsShellHelper.js'; import { IPty, IPtyForkOptions, IWindowsPtyForkOptions, spawn } from 'node-pty'; import { isNumber } from '../../../base/common/types.js'; @@ -301,7 +301,8 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess ): Promise { const args = shellIntegrationInjection?.newArgs || shellLaunchConfig.args || []; await this._throttleKillSpawn(); - this._logService.trace('node-pty.IPty#spawn', shellLaunchConfig.executable, args, options); + const sanitizedOptions = { ...options, env: sanitizeEnvForLogging(options.env as IProcessEnvironment | undefined) }; + this._logService.trace('node-pty.IPty#spawn', shellLaunchConfig.executable, args, sanitizedOptions); const ptyProcess = spawn(shellLaunchConfig.executable!, args, options); this._ptyProcess = ptyProcess; this._childProcessMonitor = this._register(new ChildProcessMonitor(ptyProcess.pid, this._logService)); diff --git a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts index a4d17386a22..bb03a05958b 100644 --- a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITerminalProcessOptions } from '../../common/terminal.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure } from '../../node/terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure, sanitizeEnvForLogging } from '../../node/terminalEnvironment.js'; const enabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: true, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; const disabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; @@ -257,4 +257,92 @@ suite('platform - terminalEnvironment', async () => { }); }); }); + + suite('sanitizeEnvForLogging', () => { + test('should return undefined for undefined input', () => { + strictEqual(sanitizeEnvForLogging(undefined), undefined); + }); + + test('should return empty object for empty input', () => { + deepStrictEqual(sanitizeEnvForLogging({}), {}); + }); + + test('should pass through non-sensitive values', () => { + deepStrictEqual(sanitizeEnvForLogging({ + PATH: '/usr/bin', + HOME: '/home/user', + TERM: 'xterm-256color' + }), { + PATH: '/usr/bin', + HOME: '/home/user', + TERM: 'xterm-256color' + }); + }); + + test('should redact sensitive env var names', () => { + deepStrictEqual(sanitizeEnvForLogging({ + API_KEY: 'secret123', + GITHUB_TOKEN: 'ghp_xxxx', + MY_SECRET: 'hidden', + PASSWORD: 'pass123', + AWS_ACCESS_KEY: 'AKIA...', + DATABASE_PASSWORD: 'dbpass', + CLIENT_SECRET: 'client_secret_value', + AUTH_TOKEN: 'auth_value', + PRIVATE_KEY: 'private_key_value' + }), { + API_KEY: '', + GITHUB_TOKEN: '', + MY_SECRET: '', + PASSWORD: '', + AWS_ACCESS_KEY: '', + DATABASE_PASSWORD: '', + CLIENT_SECRET: '', + AUTH_TOKEN: '', + PRIVATE_KEY: '' + }); + }); + + test('should redact JWT tokens by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + SOME_VAR: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' + }), { + SOME_VAR: '' + }); + }); + + test('should redact GitHub tokens by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + MY_GH: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + }), { + MY_GH: '' + }); + }); + + test('should redact Google API keys by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + GOOGLE_KEY: 'AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe' + }), { + GOOGLE_KEY: '' + }); + }); + + test('should redact long alphanumeric strings (potential secrets)', () => { + deepStrictEqual(sanitizeEnvForLogging({ + LONG_VALUE: 'abcdefghijklmnopqrstuvwxyz123456' + }), { + LONG_VALUE: '' + }); + }); + + test('should skip undefined values', () => { + const env: { [key: string]: string | undefined } = { + DEFINED: 'value', + UNDEFINED: undefined + }; + deepStrictEqual(sanitizeEnvForLogging(env), { + DEFINED: 'value' + }); + }); + }); }); From e955f18c756ae4361ff89f1914f2b610f5d7a73a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:23:42 -0800 Subject: [PATCH 47/64] Add logs around early terminal input --- .../contrib/terminal/browser/terminalProcessManager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 02b12985084..251a94fd3e6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -370,6 +370,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } this._processListeners = [ newProcess.onProcessReady((e: IProcessReadyEvent) => { + this._logService.debug('onProcessReady', e); this._processTraits = e; this.shellProcessId = e.pid; this._initialCwd = e.cwd; @@ -379,6 +380,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce if (this._preLaunchInputQueue.length > 0 && this._process) { // Send any queued data that's waiting + this._logService.debug('sending prelaunch input queue', this._preLaunchInputQueue); newProcess.input(this._preLaunchInputQueue.join('')); this._preLaunchInputQueue.length = 0; } @@ -632,6 +634,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } } else { // If the pty is not ready, queue the data received to send later + this._logService.debug('queueing data in prelaunch input queue', data); this._preLaunchInputQueue.push(data); } } From 52457c5f1fc4a69546935e4f41bcee2f704ab04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Tue, 10 Feb 2026 20:05:45 +0100 Subject: [PATCH 48/64] Chat - implement session picker in the chat panel title (#293426) * refactor layout and layout2d into base common * support anchored quick pick * wip: use anchored quick pick in scm * almost there * undo scm history view pane * Chat - implement session picker in the chat panel title * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Port changes again, and clean things up * Pull request feedback * missing changes * cleanup * fix bad merge * reduce complexity * polish * update title * Adopt the anchor * Fix compilation error * Fix monaco editor check * Enhance drag-and-drop functionality in QuickInput: add cursor style for no-drag state and enable/disable control * Fix positioning bug * fix change of behavior of layout2d * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Benjamin Pasero Co-authored-by: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../browser/ui/contextview/contextview.ts | 186 ++++-------------- src/vs/base/browser/ui/menu/menu.ts | 6 +- src/vs/base/common/layout.ts | 166 ++++++++++++++++ .../layout.test.ts} | 31 ++- .../quickinput/browser/media/quickInput.css | 5 + .../browser/quickInputController.ts | 69 ++++++- .../platform/quickinput/common/quickInput.ts | 10 + .../agentSessions/agentSessionsActions.ts | 2 +- .../agentSessions/agentSessionsPicker.ts | 2 + .../viewPane/chatViewTitleControl.ts | 4 +- 10 files changed, 310 insertions(+), 171 deletions(-) create mode 100644 src/vs/base/common/layout.ts rename src/vs/base/test/{browser/ui/contextview/contextview.test.ts => common/layout.test.ts} (60%) diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index 44c3c080e24..b3bfc63cb79 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -7,11 +7,13 @@ import { BrowserFeatures } from '../../canIUse.js'; import * as DOM from '../../dom.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js'; +import { AnchorAlignment, AnchorAxisAlignment, AnchorPosition, IRect, layout2d } from '../../../common/layout.js'; import * as platform from '../../../common/platform.js'; -import { Range } from '../../../common/range.js'; import { OmitOptional } from '../../../common/types.js'; import './contextview.css'; +export { AnchorAlignment, AnchorAxisAlignment, AnchorPosition } from '../../../common/layout.js'; + export const enum ContextViewDOMPosition { ABSOLUTE = 1, FIXED, @@ -31,18 +33,6 @@ export function isAnchor(obj: unknown): obj is IAnchor | OmitOptional { return !!anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number'; } -export const enum AnchorAlignment { - LEFT, RIGHT -} - -export const enum AnchorPosition { - BELOW, ABOVE -} - -export const enum AnchorAxisAlignment { - VERTICAL, HORIZONTAL -} - export interface IDelegate { /** * The anchor where to position the context view. @@ -73,66 +63,40 @@ export interface IContextViewProvider { layout(): void; } -export interface IPosition { - top: number; - left: number; -} +export function getAnchorRect(anchor: HTMLElement | StandardMouseEvent | IAnchor): IRect { + // Get the element's position and size (to anchor the view) + if (DOM.isHTMLElement(anchor)) { + const elementPosition = DOM.getDomNodePagePosition(anchor); -export interface ISize { - width: number; - height: number; -} + // In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element + // e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level. + // Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5 + const zoom = DOM.getDomNodeZoomLevel(anchor); -export interface IView extends IPosition, ISize { } - -export const enum LayoutAnchorPosition { - Before, - After -} - -export enum LayoutAnchorMode { - AVOID, - ALIGN -} - -export interface ILayoutAnchor { - offset: number; - size: number; - mode?: LayoutAnchorMode; // default: AVOID - position: LayoutAnchorPosition; -} - -/** - * Lays out a one dimensional view next to an anchor in a viewport. - * - * @returns The view offset within the viewport. - */ -export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): number { - const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size; - const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset; - - if (anchor.position === LayoutAnchorPosition.Before) { - if (viewSize <= viewportSize - layoutAfterAnchorBoundary) { - return layoutAfterAnchorBoundary; // happy case, lay it out after the anchor - } - - if (viewSize <= layoutBeforeAnchorBoundary) { - return layoutBeforeAnchorBoundary - viewSize; // ok case, lay it out before the anchor - } - - return Math.max(viewportSize - viewSize, 0); // sad case, lay it over the anchor + return { + top: elementPosition.top * zoom, + left: elementPosition.left * zoom, + width: elementPosition.width * zoom, + height: elementPosition.height * zoom + }; + } else if (isAnchor(anchor)) { + return { + top: anchor.y, + left: anchor.x, + width: anchor.width || 1, + height: anchor.height || 2 + }; } else { - if (viewSize <= layoutBeforeAnchorBoundary) { - return layoutBeforeAnchorBoundary - viewSize; // happy case, lay it out before the anchor - } - - - if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) { - return layoutAfterAnchorBoundary; // ok case, lay it out after the anchor - } - - - return 0; // sad case, lay it over the anchor + return { + top: anchor.posy, + left: anchor.posx, + // We are about to position the context view where the mouse + // cursor is. To prevent the view being exactly under the mouse + // when showing and thus potentially triggering an action within, + // we treat the mouse location like a small sized block element. + width: 2, + height: 2 + }; } } @@ -270,82 +234,14 @@ export class ContextView extends Disposable { } // Get anchor - const anchor = this.delegate!.getAnchor(); - - // Compute around - let around: IView; - - // Get the element's position and size (to anchor the view) - if (DOM.isHTMLElement(anchor)) { - const elementPosition = DOM.getDomNodePagePosition(anchor); - - // In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element - // e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level. - // Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5 - const zoom = DOM.getDomNodeZoomLevel(anchor); - - around = { - top: elementPosition.top * zoom, - left: elementPosition.left * zoom, - width: elementPosition.width * zoom, - height: elementPosition.height * zoom - }; - } else if (isAnchor(anchor)) { - around = { - top: anchor.y, - left: anchor.x, - width: anchor.width || 1, - height: anchor.height || 2 - }; - } else { - around = { - top: anchor.posy, - left: anchor.posx, - // We are about to position the context view where the mouse - // cursor is. To prevent the view being exactly under the mouse - // when showing and thus potentially triggering an action within, - // we treat the mouse location like a small sized block element. - width: 2, - height: 2 - }; - } - - const viewSizeWidth = DOM.getTotalWidth(this.view); - const viewSizeHeight = DOM.getTotalHeight(this.view); - - const anchorPosition = this.delegate!.anchorPosition ?? AnchorPosition.BELOW; - const anchorAlignment = this.delegate!.anchorAlignment ?? AnchorAlignment.LEFT; - const anchorAxisAlignment = this.delegate!.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL; - - let top: number; - let left: number; - + const anchor = getAnchorRect(this.delegate!.getAnchor()); const activeWindow = DOM.getActiveWindow(); - if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) { - const verticalAnchor: ILayoutAnchor = { offset: around.top - activeWindow.pageYOffset, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; - const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; - - top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset; - - // if view intersects vertically with anchor, we must avoid the anchor - if (Range.intersects({ start: top, end: top + viewSizeHeight }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) { - horizontalAnchor.mode = LayoutAnchorMode.AVOID; - } - - left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor); - } else { - const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; - const verticalAnchor: ILayoutAnchor = { offset: around.top, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; - - left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor); - - // if view intersects horizontally with anchor, we must avoid the anchor - if (Range.intersects({ start: left, end: left + viewSizeWidth }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) { - verticalAnchor.mode = LayoutAnchorMode.AVOID; - } - - top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset; - } + const viewport = { top: activeWindow.pageYOffset, left: activeWindow.pageXOffset, width: activeWindow.innerWidth, height: activeWindow.innerHeight }; + const view = { width: DOM.getTotalWidth(this.view), height: DOM.getTotalHeight(this.view) }; + const anchorPosition = this.delegate!.anchorPosition; + const anchorAlignment = this.delegate!.anchorAlignment; + const anchorAxisAlignment = this.delegate!.anchorAxisAlignment; + const { top, left } = layout2d(viewport, view, anchor, { anchorAlignment, anchorPosition, anchorAxisAlignment }); this.view.classList.remove('top', 'bottom', 'left', 'right'); this.view.classList.add(anchorPosition === AnchorPosition.BELOW ? 'bottom' : 'top'); diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index f48c4488073..c747ea1cd87 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -11,7 +11,6 @@ import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../actionbar/actionViewItems.js'; -import { AnchorAlignment, layout, LayoutAnchorPosition } from '../contextview/contextview.js'; import { DomScrollableElement } from '../scrollbar/scrollableElement.js'; import { EmptySubmenuAction, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js'; import { RunOnceScheduler } from '../../../common/async.js'; @@ -26,6 +25,7 @@ import { DisposableStore } from '../../../common/lifecycle.js'; import { isLinux, isMacintosh } from '../../../common/platform.js'; import { ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import * as strings from '../../../common/strings.js'; +import { AnchorAlignment, layout, LayoutAnchorPosition } from '../../../common/layout.js'; export const MENU_MNEMONIC_REGEX = /\(&([^\s&])\)|(^|[^&])&([^\s&])/; export const MENU_ESCAPED_MNEMONIC_REGEX = /(&)?(&)([^\s&])/g; @@ -859,7 +859,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { const ret = { top: 0, left: 0 }; // Start with horizontal - ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }); + ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }).position; // We don't have enough room to layout the menu fully, so we are overlapping the menu if (ret.left >= entry.left && ret.left < entry.left + entry.width) { @@ -872,7 +872,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { } // Now that we have a horizontal position, try layout vertically - ret.top = layout(windowDimensions.height, submenu.height, { position: LayoutAnchorPosition.Before, offset: entry.top, size: 0 }); + ret.top = layout(windowDimensions.height, submenu.height, { position: LayoutAnchorPosition.Before, offset: entry.top, size: 0 }).position; // We didn't have enough room below, but we did above, so we shift down to align the menu if (ret.top + submenu.height === entry.top && ret.top + entry.height + submenu.height <= windowDimensions.height) { diff --git a/src/vs/base/common/layout.ts b/src/vs/base/common/layout.ts new file mode 100644 index 00000000000..b3ca8f372b1 --- /dev/null +++ b/src/vs/base/common/layout.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from './range.js'; + +export interface IAnchor { + x: number; + y: number; + width?: number; + height?: number; +} + +export const enum AnchorAlignment { + LEFT, RIGHT +} + +export const enum AnchorPosition { + BELOW, ABOVE +} + +export const enum AnchorAxisAlignment { + VERTICAL, HORIZONTAL +} + +interface IPosition { + readonly top: number; + readonly left: number; +} + +interface ISize { + readonly width: number; + readonly height: number; +} + +export interface IRect extends IPosition, ISize { } + +export const enum LayoutAnchorPosition { + Before, + After +} + +export enum LayoutAnchorMode { + AVOID, + ALIGN +} + +export interface ILayoutAnchor { + offset: number; + size: number; + mode?: LayoutAnchorMode; // default: AVOID + position: LayoutAnchorPosition; +} + +export interface ILayoutResult { + position: number; + result: 'ok' | 'flipped' | 'overlap'; +} + +/** + * Lays out a one dimensional view next to an anchor in a viewport. + * + * @returns The view offset within the viewport. + */ +export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): ILayoutResult { + const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size; + const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset; + + if (anchor.position === LayoutAnchorPosition.Before) { + if (viewSize <= viewportSize - layoutAfterAnchorBoundary) { + return { position: layoutAfterAnchorBoundary, result: 'ok' }; // happy case, lay it out after the anchor + } + + if (viewSize <= layoutBeforeAnchorBoundary) { + return { position: layoutBeforeAnchorBoundary - viewSize, result: 'flipped' }; // ok case, lay it out before the anchor + } + + return { position: Math.max(viewportSize - viewSize, 0), result: 'overlap' }; // sad case, lay it over the anchor + } else { + if (viewSize <= layoutBeforeAnchorBoundary) { + return { position: layoutBeforeAnchorBoundary - viewSize, result: 'ok' }; // happy case, lay it out before the anchor + } + + if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) { + return { position: layoutAfterAnchorBoundary, result: 'flipped' }; // ok case, lay it out after the anchor + } + + return { position: 0, result: 'overlap' }; // sad case, lay it over the anchor + } +} + +interface ILayout2DOptions { + readonly anchorAlignment?: AnchorAlignment; // default: left + readonly anchorPosition?: AnchorPosition; // default: above + readonly anchorAxisAlignment?: AnchorAxisAlignment; // default: vertical +} + +export interface ILayout2DResult { + top: number; + left: number; + bottom: number; + right: number; + anchorAlignment: AnchorAlignment; + anchorPosition: AnchorPosition; +} + +export function layout2d(viewport: IRect, view: ISize, anchor: IRect, options?: ILayout2DOptions): ILayout2DResult { + let anchorAlignment = options?.anchorAlignment ?? AnchorAlignment.LEFT; + let anchorPosition = options?.anchorPosition ?? AnchorPosition.BELOW; + const anchorAxisAlignment = options?.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL; + + let top: number; + let left: number; + + if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) { + const verticalAnchor: ILayoutAnchor = { offset: anchor.top - viewport.top, size: anchor.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; + const horizontalAnchor: ILayoutAnchor = { offset: anchor.left, size: anchor.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; + + const verticalLayoutResult = layout(viewport.height, view.height, verticalAnchor); + top = verticalLayoutResult.position + viewport.top; + + if (verticalLayoutResult.result === 'flipped') { + anchorPosition = anchorPosition === AnchorPosition.BELOW ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + + // if view intersects vertically with anchor, we must avoid the anchor + if (Range.intersects({ start: top, end: top + view.height }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) { + horizontalAnchor.mode = LayoutAnchorMode.AVOID; + } + + const horizontalLayoutResult = layout(viewport.width, view.width, horizontalAnchor); + left = horizontalLayoutResult.position; + + if (horizontalLayoutResult.result === 'flipped') { + anchorAlignment = anchorAlignment === AnchorAlignment.LEFT ? AnchorAlignment.RIGHT : AnchorAlignment.LEFT; + } + } else { + const horizontalAnchor: ILayoutAnchor = { offset: anchor.left, size: anchor.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; + const verticalAnchor: ILayoutAnchor = { offset: anchor.top, size: anchor.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; + + const horizontalLayoutResult = layout(viewport.width, view.width, horizontalAnchor); + left = horizontalLayoutResult.position; + + if (horizontalLayoutResult.result === 'flipped') { + anchorAlignment = anchorAlignment === AnchorAlignment.LEFT ? AnchorAlignment.RIGHT : AnchorAlignment.LEFT; + } + + // if view intersects horizontally with anchor, we must avoid the anchor + if (Range.intersects({ start: left, end: left + view.width }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) { + verticalAnchor.mode = LayoutAnchorMode.AVOID; + } + + const verticalLayoutResult = layout(viewport.height, view.height, verticalAnchor); + top = verticalLayoutResult.position + viewport.top; + + if (verticalLayoutResult.result === 'flipped') { + anchorPosition = anchorPosition === AnchorPosition.BELOW ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + } + + const right = viewport.width - (left + view.width); + const bottom = viewport.height - (top + view.height); + + return { top, left, bottom, right, anchorAlignment, anchorPosition }; +} diff --git a/src/vs/base/test/browser/ui/contextview/contextview.test.ts b/src/vs/base/test/common/layout.test.ts similarity index 60% rename from src/vs/base/test/browser/ui/contextview/contextview.test.ts rename to src/vs/base/test/common/layout.test.ts index 4058d33f4a9..a6be1ea8ed2 100644 --- a/src/vs/base/test/browser/ui/contextview/contextview.test.ts +++ b/src/vs/base/test/common/layout.test.ts @@ -4,27 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { layout, LayoutAnchorPosition } from '../../../../browser/ui/contextview/contextview.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; +import { layout, LayoutAnchorPosition } from '../../common/layout.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; -suite('Contextview', function () { +suite('Layout', function () { test('layout', () => { - assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.Before }), 0); - assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.Before }), 50); - assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.Before }), 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.Before }).position, 0); + assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.Before }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.Before }).position, 180); - assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.After }), 0); - assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.After }), 30); - assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.After }), 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.After }).position, 0); + assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.After }).position, 30); + assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.After }).position, 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.Before }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.Before }).position, 100); + assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.Before }).position, 130); - assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.Before }), 50); - assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.Before }), 100); - assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.Before }), 130); - - assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.After }), 50); - assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.After }), 30); - assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.After }), 130); + assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.After }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.After }).position, 30); + assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.After }).position, 130); }); ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 0636687742d..bd509719a3c 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -20,6 +20,11 @@ border-top-left-radius: 5px; } +.quick-input-widget.no-drag .quick-input-titlebar, +.quick-input-widget.no-drag .quick-input-title, +.quick-input-widget.no-drag .quick-input-header { + cursor: default; +} .quick-input-widget .monaco-inputbox .monaco-action-bar { top: 0; } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index fae2f7ffb45..8e5283ef9ad 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -37,6 +37,8 @@ import { TriStateCheckbox, createToggleActionViewItemProvider } from '../../../b import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; +import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; +import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; const $ = dom.$; @@ -541,6 +543,7 @@ export class QuickInputController extends Disposable { input.quickNavigate = options.quickNavigate; input.hideInput = !!options.hideInput; input.contextKey = options.contextKey; + input.anchor = options.anchor; input.busy = true; Promise.all([picks, options.activeItem]) .then(([items, _activeItem]) => { @@ -710,6 +713,7 @@ export class QuickInputController extends Disposable { ui.container.style.display = ''; this.updateLayout(); + this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); @@ -861,16 +865,52 @@ export class QuickInputController extends Disposable { private updateLayout() { if (this.ui && this.isVisible()) { const style = this.ui.container.style; - const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); + let width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); style.width = width + 'px'; + let listHeight = this.dimension && this.dimension.height * 0.4; + // Position - style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; - style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; + if (this.controller?.anchor) { + const container = this.layoutService.getContainer(dom.getActiveWindow()).getBoundingClientRect(); + const anchor = getAnchorRect(this.controller.anchor); + width = 380; + listHeight = this.dimension ? Math.min(this.dimension.height * 0.2, 200) : 200; + + // Beware: + // We need to add some extra pixels to the height to account for the input and padding. + const containerHeight = Math.floor(listHeight) + 6 + 26 + 16; + const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: AnchorPosition.ABOVE }); + + if (anchorAlignment === AnchorAlignment.RIGHT) { + style.right = `${right}px`; + style.left = 'initial'; + } else { + style.left = `${left}px`; + style.right = 'initial'; + } + + if (anchorPosition === AnchorPosition.ABOVE) { + style.bottom = `${bottom}px`; + style.top = 'initial'; + } else { + style.top = `${top}px`; + style.bottom = 'initial'; + } + + style.width = `${width}px`; + style.height = ''; + } else { + style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; + style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; + style.right = ''; + style.bottom = ''; + style.height = ''; + } this.ui.inputBox.layout(); - this.ui.list.layout(this.dimension && this.dimension.height * 0.4); - this.ui.tree.layout(this.dimension && this.dimension.height * 0.4); + this.ui.list.layout(listHeight); + this.ui.tree.layout(listHeight); } } @@ -965,6 +1005,8 @@ export interface IQuickInputControllerHost extends ILayoutService { } class QuickInputDragAndDropController extends Disposable { readonly dndViewState = observableValue<{ top?: number; left?: number; done: boolean } | undefined>(this, undefined); + private _enabled = true; + private readonly _snapThreshold = 20; private readonly _snapLineHorizontalRatio = 0.25; @@ -1000,6 +1042,10 @@ class QuickInputDragAndDropController extends Disposable { } layoutContainer(dimension = this._layoutService.activeContainerDimension): void { + if (!this._enabled) { + return; + } + const state = this.dndViewState.get(); const dragAreaRect = this._quickInputContainer.getBoundingClientRect(); if (state?.top && state?.left) { @@ -1011,6 +1057,11 @@ class QuickInputDragAndDropController extends Disposable { } } + setEnabled(enabled: boolean): void { + this._enabled = enabled; + this._quickInputContainer.classList.toggle('no-drag', !enabled); + } + setAlignment(alignment: 'top' | 'center' | { top: number; left: number }, done = true): void { if (alignment === 'top') { this.dndViewState.set({ @@ -1041,6 +1092,10 @@ class QuickInputDragAndDropController extends Disposable { // Double click this._register(dom.addDisposableGenericMouseUpListener(dragArea, (event: MouseEvent) => { + if (!this._enabled) { + return; + } + const originEvent = new StandardMouseEvent(dom.getWindow(dragArea), event); if (originEvent.detail !== 2) { return; @@ -1057,6 +1112,10 @@ class QuickInputDragAndDropController extends Disposable { // Mouse down this._register(dom.addDisposableGenericMouseDownListener(dragArea, (e: MouseEvent) => { + if (!this._enabled) { + return; + } + const activeWindow = dom.getWindow(this._layoutService.activeContainer); const originEvent = new StandardMouseEvent(activeWindow, e); diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9ff9d71fe6c..9426be48e2f 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -197,6 +197,11 @@ export interface IPickOptions { */ activeItem?: Promise | T; + /** + * an optional anchor for the picker + */ + anchor?: HTMLElement | { x: number; y: number }; + onKeyMods?: (keyMods: IKeyMods) => void; onDidFocus?: (entry: T) => void; onDidTriggerItemButton?: (context: IQuickPickItemButtonContext) => void; @@ -353,6 +358,11 @@ export interface IQuickInput extends IDisposable { */ ignoreFocusOut: boolean; + /** + * An optional anchor for the quick input. + */ + anchor?: HTMLElement | { x: number; y: number }; + /** * Shows the quick input. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 0af4544f3e9..c07c6e5111a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -149,7 +149,7 @@ export class PickAgentSessionAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, undefined); await agentSessionsPicker.pickAgentSession(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index 9251a44e9fe..27fe44366cb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -67,6 +67,7 @@ export class AgentSessionsPicker { private readonly sorter = new AgentSessionsSorter(); constructor( + private readonly anchor: HTMLElement | undefined, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -78,6 +79,7 @@ export class AgentSessionsPicker { const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); + picker.anchor = this.anchor; picker.items = this.createPickerItems(filter); picker.canAcceptInBackground = true; picker.placeholder = localize('chatAgentPickerPlaceholder', "Search agent sessions by name"); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index 53769e1c43c..0cbc75d1883 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -59,6 +59,8 @@ export class ChatViewTitleControl extends Disposable { } private registerActions(): void { + const that = this; + this._register(registerAction2(class extends Action2 { constructor() { super({ @@ -76,7 +78,7 @@ export class ChatViewTitleControl extends Disposable { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, that.titleLabel.value?.element); await agentSessionsPicker.pickAgentSession(); } })); From 891dbff6f2a46690545d06b95cd02b9435988504 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 13:11:23 -0600 Subject: [PATCH 49/64] tips cleanup, improvements (#294177) --- .../contrib/chat/browser/chatTipService.ts | 37 ++++---- .../chatContentParts/chatTipContentPart.ts | 10 +-- .../chat/browser/widget/chatListRenderer.ts | 10 +-- .../chat/test/browser/chatTipService.test.ts | 84 +++++++++++++++---- 4 files changed, 96 insertions(+), 45 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 309d5b366f2..a26d065d8a9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../common/constants.js'; @@ -14,9 +14,11 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { AgentFileType, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -57,7 +59,7 @@ export interface IChatTipService { /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ - disableTips(): void; + disableTips(): Promise; } export interface ITipDefinition { @@ -153,9 +155,9 @@ export class TipEligibilityTracker extends Disposable { constructor( tips: readonly ITipDefinition[], - commandService: ICommandService, - private readonly _storageService: IStorageService, - promptsService: IPromptsService, + @ICommandService commandService: ICommandService, + @IStorageService private readonly _storageService: IStorageService, + @IPromptsService promptsService: IPromptsService, ) { super(); @@ -262,8 +264,12 @@ export class TipEligibilityTracker extends Disposable { private async _checkForInstructionFiles(promptsService: IPromptsService): Promise { try { - const files = await promptsService.listAgentInstructions(CancellationToken.None); - this._hasInstructionFiles = files.length > 0; + const [agentInstructions, instructionFiles] = await Promise.all([ + promptsService.listAgentInstructions(CancellationToken.None), + promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + ]); + const hasCopilotInstructions = agentInstructions.some(f => f.type === AgentFileType.copilotInstructionsMd); + this._hasInstructionFiles = hasCopilotInstructions || instructionFiles.length > 0; } catch { this._hasInstructionFiles = true; } @@ -312,14 +318,11 @@ export class ChatTipService extends Disposable implements IChatTipService { @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService private readonly _storageService: IStorageService, - @ICommandService commandService: ICommandService, - @IStorageService storageService: IStorageService, - @IPromptsService promptsService: IPromptsService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, ) { super(); - this._tracker = this._register(new TipEligibilityTracker( - TIP_CATALOG, commandService, storageService, promptsService, - )); + this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); } dismissTip(): void { @@ -341,17 +344,18 @@ export class ChatTipService extends Disposable implements IChatTipService { } try { const parsed = JSON.parse(raw); + this._logService.debug('#ChatTips dismissed:', parsed); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } - disableTips(): void { + async disableTips(): Promise { this._hasShownTip = false; this._shownTip = undefined; this._tipRequestId = undefined; - this._configurationService.updateValue('chat.tips.enabled', false); + await this._configurationService.updateValue('chat.tips.enabled', false); this._onDidDisableTips.fire(); } @@ -406,6 +410,7 @@ export class ChatTipService extends Disposable implements IChatTipService { private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) { + this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); return false; } return !this._tracker.isExcluded(tip); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 0e71dfec5c1..df83eb5d194 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -32,11 +32,11 @@ export class ChatTipContentPart extends Disposable { constructor( tip: IChatTip, private readonly _renderer: IMarkdownRenderer, - private readonly _chatTipService: IChatTipService, - private readonly _contextMenuService: IContextMenuService, - private readonly _menuService: IMenuService, - private readonly _contextKeyService: IContextKeyService, private readonly _getNextTip: () => IChatTip | undefined, + @IChatTipService private readonly _chatTipService: IChatTipService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IMenuService private readonly _menuService: IMenuService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, ) { super(); @@ -114,7 +114,7 @@ registerAction2(class DisableTipsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - accessor.get(IChatTipService).disableTips(); + await accessor.get(IChatTipService).disableTips(); } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 0d812eb533f..38d1242b302 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -34,7 +34,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; -import { MenuId, MenuItemAction, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -254,8 +254,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), ); templateData.value.appendChild(tipPart.domNode); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 5da31f55818..0c9f6dc9c7e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -12,12 +12,15 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; -import { IPromptsService, IResolvedAgentFile } from '../../common/promptSyntax/service/promptsService.js'; +import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../../common/constants.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(): boolean { @@ -34,6 +37,7 @@ suite('ChatTipService', () => { let commandExecutedEmitter: Emitter; let storageService: InMemoryStorageService; let mockInstructionFiles: IResolvedAgentFile[]; + let mockPromptInstructionFiles: IPromptPath[]; function createProductService(hasCopilot: boolean): IProductService { return { @@ -55,15 +59,18 @@ suite('ChatTipService', () => { commandExecutedEmitter = testDisposables.add(new Emitter()); storageService = testDisposables.add(new InMemoryStorageService()); mockInstructionFiles = []; + mockPromptInstructionFiles = []; instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IStorageService, storageService); + instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(ICommandService, { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: testDisposables.add(new Emitter()).event, } as Partial as ICommandService); instantiationService.stub(IPromptsService, { listAgentInstructions: async () => mockInstructionFiles, + listPromptFiles: async () => mockPromptInstructionFiles, } as Partial as IPromptsService); }); @@ -190,7 +197,7 @@ suite('ChatTipService', () => { assert.ok(fired, 'onDidDismissTip should fire'); }); - test('disableTips fires onDidDisableTips event', () => { + test('disableTips fires onDidDisableTips event', async () => { const service = createService(); const now = Date.now(); @@ -198,12 +205,12 @@ suite('ChatTipService', () => { let fired = false; testDisposables.add(service.onDidDisableTips(() => { fired = true; })); - service.disableTips(); + await service.disableTips(); assert.ok(fired, 'onDidDisableTips should fire'); }); - test('disableTips resets state so re-enabling works', () => { + test('disableTips resets state so re-enabling works', async () => { const service = createService(); const now = Date.now(); @@ -212,7 +219,7 @@ suite('ChatTipService', () => { assert.ok(tip1); // Disable tips - service.disableTips(); + await service.disableTips(); // Re-enable tips configurationService.setUserConfiguration('chat.tips.enabled', true); @@ -222,6 +229,13 @@ suite('ChatTipService', () => { assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); + function createMockPromptsService(agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = []): Partial { + return { + listAgentInstructions: async () => agentInstructions, + listPromptFiles: async (_type: PromptsType) => promptInstructions, + }; + } + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { const tip: ITipDefinition = { id: 'tip.undoChanges', @@ -233,7 +247,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before command is executed'); @@ -243,7 +257,7 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after command is executed'); }); - test('excludes tip.customInstructions when instruction files exist in workspace', async () => { + test('excludes tip.customInstructions when copilot-instructions.md exists in workspace', async () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', @@ -253,13 +267,51 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [{ uri: { path: '/.github/copilot-instructions.md' } } as IResolvedAgentFile] } as Partial as IPromptsService, + createMockPromptsService([{ uri: { path: '/.github/copilot-instructions.md' }, realPath: undefined, type: AgentFileType.copilotInstructionsMd } as IResolvedAgentFile]) as IPromptsService, )); // Wait for the async file check to complete await new Promise(r => setTimeout(r, 0)); - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when instruction files exist'); + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when copilot-instructions.md exists'); + }); + + test('does not exclude tip.customInstructions when only AGENTS.md exists', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([{ uri: { path: '/AGENTS.md' }, realPath: undefined, type: AgentFileType.agentsMd } as IResolvedAgentFile]) as IPromptsService, + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when only AGENTS.md exists'); + }); + + test('excludes tip.customInstructions when .instructions.md files exist in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when .instructions.md files exist'); }); test('does not exclude tip.customInstructions when no instruction files exist', async () => { @@ -272,7 +324,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); // Wait for the async file check to complete @@ -295,7 +347,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -319,7 +371,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -340,7 +392,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); @@ -351,7 +403,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted exclusion from workspace storage'); @@ -371,7 +423,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); tracker1.recordCurrentMode(contextKeyService); @@ -382,7 +434,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); From 97f7c15f5dafd1248897ef223b2f0a29882c0179 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 13:27:14 -0600 Subject: [PATCH 50/64] do not use same kb for question carousel and confirmation dialog (#294182) fix #294039 --- src/vs/workbench/contrib/chat/browser/actions/chatActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index fefa2b156dd..24b02b3d54a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -793,7 +793,7 @@ export function registerChatActions() { precondition: ChatContextKeys.inChatSession, keybinding: [{ weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyH, when: ChatContextKeys.inChatSession, }] }); From 2050e3c2c3cb4da31cd32bb7e0a40012920e5992 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 13:55:00 -0600 Subject: [PATCH 51/64] Terminal: update auto replies configuration (#294207) fix #294200 --- .../autoReplies/common/terminalAutoRepliesConfiguration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts index 4979520e356..dc20533be3f 100644 --- a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts @@ -26,6 +26,7 @@ export const terminalAutoRepliesConfiguration: IStringDictionary Date: Tue, 10 Feb 2026 12:20:41 -0800 Subject: [PATCH 52/64] feat: force chat setting to be in subcategory (#294212) --- .../contrib/preferences/browser/settingsLayout.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 30b1f1f9524..dc0bd76f057 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -174,7 +174,7 @@ export const tocData: ITOCEntry = { { id: 'chat', label: localize('chat', "Chat"), - settings: ['chat.*'], + settings: [], children: [ { id: 'chat/agent', @@ -187,7 +187,8 @@ export const tocData: ITOCEntry = { 'chat.undoRequests.*', 'chat.customAgentInSubagent.*', 'chat.editing.autoAcceptDelay', - 'chat.editing.confirmEditRequest*' + 'chat.editing.confirmEditRequest*', + 'chat.planAgent.defaultModel' ] }, { @@ -209,7 +210,8 @@ export const tocData: ITOCEntry = { 'chat.notifyWindow*', 'chat.statusWidget.*', 'chat.tips.*', - 'chat.unifiedAgentsBar.*' + 'chat.unifiedAgentsBar.*', + 'chat.confettiOnThumbsUp' ] }, { @@ -259,7 +261,8 @@ export const tocData: ITOCEntry = { 'chat.useChatHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', - 'chat.sendElementsToChat.*' + 'chat.sendElementsToChat.*', + 'chat.useClaudeMdFile' ] }, { From 8efd494dc82a8e8b322609df3dc91baa4beb37c7 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:21:40 -0800 Subject: [PATCH 53/64] Allow contributed configurations to define additional "search terms" --- .../configuration/common/configurationRegistry.ts | 5 +++++ .../workbench/api/common/configurationExtensionPoint.ts | 7 +++++++ .../contrib/preferences/browser/preferencesSearch.ts | 9 ++++++--- .../workbench/services/preferences/common/preferences.ts | 1 + .../services/preferences/common/preferencesModels.ts | 1 + 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index f12e732db79..c26fc939188 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -200,6 +200,11 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ enumItemLabels?: string[]; + /** + * Optional terms used for search purposes. + */ + searchTerms?: string[]; + /** * When specified, controls the presentation format of string settings. * Otherwise, the presentation format defaults to `singleline`. diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 4a2cf457445..6f777af406a 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -116,6 +116,13 @@ const configurationEntrySchema: IJSONSchema = { type: 'boolean', description: nls.localize('scope.ignoreSync', 'When enabled, Settings Sync will not sync the user value of this configuration by default.') }, + searchTerms: { + type: 'array', + items: { + type: 'string' + }, + description: nls.localize('scope.searchTerms', 'A list of additional search terms that help users find this setting in the Settings editor. These are not shown to the user.') + }, tags: { type: 'array', items: { diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 3721b873139..0cfcac7318d 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -242,10 +242,13 @@ export class SettingMatches { // Search the description if we found non-contiguous key matches at best. const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel; if (this.searchDescription && !hasContiguousKeyMatchTypes) { + // Search the description lines and any additional search terms. + const searchableLines = setting.searchTerms?.length + ? [...setting.description, setting.searchTerms.join(' ')] + : setting.description; for (const word of queryWords) { - // Search the description lines. - for (let lineIndex = 0; lineIndex < setting.description.length; lineIndex++) { - const descriptionMatches = matchesBaseContiguousSubString(word, setting.description[lineIndex]); + for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) { + const descriptionMatches = matchesBaseContiguousSubString(word, searchableLines[lineIndex]); if (descriptionMatches?.length) { descriptionMatchingWords.set(word, descriptionMatches.map(match => this.toDescriptionRange(setting, match, lineIndex))); } diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 4fc67f48798..08cb72355db 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -64,6 +64,7 @@ export interface ISetting { value: any; valueRange: IRange; description: string[]; + searchTerms?: string[]; descriptionIsMarkdown?: boolean; descriptionRanges: IRange[]; overrides?: ISetting[]; diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 3dc1900f41a..79fc7bc33c5 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -723,6 +723,7 @@ export class DefaultSettings extends Disposable { value, description: descriptionLines, descriptionIsMarkdown: !!prop.markdownDescription, + searchTerms: prop.searchTerms, range: nullRange, keyRange: nullRange, valueRange: nullRange, From d434a65945c2f2519bc2d64e42352d8dc91b302a Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:32:40 -0800 Subject: [PATCH 54/64] Use esbuild to pack the markdown extension for desktop and web (#294208) * Try using esbuild to bundle our built-in extensions Test switching to esbuild instead of webpack to bundle our buildin extensions. Setup so we can do this incrementally and starting with the markdown extension as a test * Fix build ext media * Fix .ts script name check * Update comment * Use ts for all scripts --- build/gulpfile.extensions.ts | 30 +++++++- build/lib/extensions.ts | 75 ++++++++++++++++-- eslint.config.js | 4 +- extensions/esbuild-extension-common.ts | 77 +++++++++++++++++++ .../esbuild-browser.ts | 40 ++++++++++ .../markdown-language-features/esbuild.ts | 27 +++++++ .../extension-browser.webpack.config.js | 27 ------- .../extension.webpack.config.js | 28 ------- 8 files changed, 241 insertions(+), 67 deletions(-) create mode 100644 extensions/esbuild-extension-common.ts create mode 100644 extensions/markdown-language-features/esbuild-browser.ts create mode 100644 extensions/markdown-language-features/esbuild.ts delete mode 100644 extensions/markdown-language-features/extension-browser.webpack.config.js delete mode 100644 extensions/markdown-language-features/extension.webpack.config.js diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index a310fbbe548..f48738be53a 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -273,11 +273,33 @@ gulp.task(compileWebExtensionsTask); export const watchWebExtensionsTask = task.define('watch-web', () => buildWebExtensions(true)); gulp.task(watchWebExtensionsTask); -async function buildWebExtensions(isWatch: boolean) { +async function buildWebExtensions(isWatch: boolean): Promise { const extensionsPath = path.join(root, 'extensions'); - const webpackConfigLocations = await nodeUtil.promisify(glob)( - path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), + + // Find all esbuild-browser.ts files + const esbuildConfigLocations = await nodeUtil.promisify(glob)( + path.join(extensionsPath, '**', 'esbuild-browser.ts'), { ignore: ['**/node_modules'] } ); - return ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath }))); + + // Find all webpack configs, excluding those that will be esbuilt + const esbuildExtensionDirs = new Set(esbuildConfigLocations.map(p => path.dirname(p))); + const webpackConfigLocations = (await nodeUtil.promisify(glob)( + path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), + { ignore: ['**/node_modules'] } + )).filter(configPath => !esbuildExtensionDirs.has(path.dirname(configPath))); + + const promises: Promise[] = []; + + // Esbuild for extensions + if (esbuildConfigLocations.length > 0) { + promises.push(ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script })))); + } + + // Run webpack for remaining extensions + if (webpackConfigLocations.length > 0) { + promises.push(ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath })))); + } + + await Promise.all(promises); } diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index e06f1510a66..cea54bff8b9 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -66,16 +66,31 @@ function updateExtensionPackageJSON(input: Stream, update: (data: any) => any): function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolean): Stream { + const esbuildConfigFileName = forWeb + ? 'esbuild-browser.ts' + : 'esbuild.ts'; + const webpackConfigFileName = forWeb ? `extension-browser.webpack.config.js` : `extension.webpack.config.js`; + const hasEsbuild = fs.existsSync(path.join(extensionPath, esbuildConfigFileName)); const isWebPacked = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); - let input = isWebPacked - ? fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle) - : fromLocalNormal(extensionPath); - if (isWebPacked) { + let input: Stream; + let isBundled = false; + + if (hasEsbuild) { + input = fromLocalEsbuild(extensionPath, esbuildConfigFileName); + isBundled = true; + } else if (isWebPacked) { + input = fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle); + isBundled = true; + } else { + input = fromLocalNormal(extensionPath); + } + + if (isBundled) { input = updateExtensionPackageJSON(input, (data: any) => { delete data.scripts; delete data.dependencies; @@ -240,6 +255,51 @@ function fromLocalNormal(extensionPath: string): Stream { return result.pipe(createStatsStream(path.basename(extensionPath))); } +function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): Stream { + const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); + const result = es.through(); + + const esbuildScript = path.join(extensionPath, esbuildConfigFileName); + + // Run esbuild, then collect the files + new Promise((resolve, reject) => { + const proc = cp.execFile(process.argv[0], [esbuildScript], {}, (error, _stdout, stderr) => { + if (error) { + return reject(error); + } + const matches = (stderr || '').match(/\> (.+): error: (.+)?/g); + fancyLog(`Bundled extension: ${ansiColors.yellow(path.join(path.basename(extensionPath), esbuildConfigFileName))} with ${matches ? matches.length : 0} errors.`); + for (const match of matches || []) { + fancyLog.error(match); + } + return resolve(); + }); + + proc.stdout!.on('data', (data) => { + fancyLog(`${ansiColors.green('esbuilding')}: ${data.toString('utf8')}`); + }); + }).then(() => { + // After esbuild completes, collect all files using vsce + return vsce.listFiles({ cwd: extensionPath, packageManager: vsce.PackageManager.None }); + }).then(fileNames => { + const files = fileNames + .map(fileName => path.join(extensionPath, fileName)) + .map(filePath => new File({ + path: filePath, + stat: fs.statSync(filePath), + base: extensionPath, + contents: fs.createReadStream(filePath) + })); + + es.readArray(files).pipe(result); + }).catch(err => { + console.error(extensionPath); + result.emit('error', err); + }); + + return result.pipe(createStatsStream(path.basename(extensionPath))); +} + const userAgent = 'VSCode Build'; const baseHeaders = { 'X-Market-Client-Id': 'VSCode Build', @@ -647,7 +707,7 @@ export async function webpackExtensions(taskName: string, isWatch: boolean, webp }); } -async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]) { +export async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]): Promise { function reporter(stdError: string, script: string) { const matches = (stdError || '').match(/\> (.+): error: (.+)?/g); fancyLog(`Finished ${ansiColors.green(taskName)} ${script} with ${matches ? matches.length : 0} errors.`); @@ -678,10 +738,11 @@ async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { }); }); }); - return Promise.all(tasks); + + await Promise.all(tasks); } -export async function buildExtensionMedia(isWatch: boolean, outputRoot?: string) { +export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ script: path.join(extensionsPath, p), outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined diff --git a/eslint.config.js b/eslint.config.js index 96e1232427b..fa55c74032c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2064,7 +2064,9 @@ export default tseslint.config( // Additional extension strictness rules { files: [ - 'extensions/markdown-language-features/**/*.ts', + 'extensions/markdown-language-features/src/**/*.ts', + 'extensions/markdown-language-features/notebook/**/*.ts', + 'extensions/markdown-language-features/preview-src/**/*.ts', 'extensions/mermaid-chat-features/**/*.ts', 'extensions/media-preview/**/*.ts', 'extensions/simple-browser/**/*.ts', diff --git a/extensions/esbuild-extension-common.ts b/extensions/esbuild-extension-common.ts new file mode 100644 index 00000000000..513656ae89f --- /dev/null +++ b/extensions/esbuild-extension-common.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/** + * @fileoverview Common build script for extensions. + */ +import path from 'node:path'; +import esbuild from 'esbuild'; + +type BuildOptions = Partial & { + outdir: string; +}; + +/** + * Build the source code once using esbuild. + */ +async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { + await esbuild.build({ + bundle: true, + minify: true, + sourcemap: false, + format: 'cjs', + platform: 'node', + target: ['es2024'], + external: ['vscode'], + ...options, + }); + + await didBuild?.(options.outdir); +} + +/** + * Build the source code once using esbuild, logging errors instead of throwing. + */ +async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { + try { + await build(options, didBuild); + } catch (err) { + console.error(err); + } +} + +interface RunConfig { + srcDir: string; + outdir: string; + entryPoints: string[] | Record | { in: string; out: string }[]; + additionalOptions?: Partial; +} + +export async function run(config: RunConfig, args: string[], didBuild?: (outDir: string) => unknown): Promise { + let outdir = config.outdir; + const outputRootIndex = args.indexOf('--outputRoot'); + if (outputRootIndex >= 0) { + const outputRoot = args[outputRootIndex + 1]; + const outputDirName = path.basename(outdir); + outdir = path.join(outputRoot, outputDirName); + } + + const resolvedOptions: BuildOptions = { + entryPoints: config.entryPoints, + outdir, + logOverride: { + 'import-is-undefined': 'error', + }, + ...(config.additionalOptions || {}), + }; + + const isWatch = args.indexOf('--watch') >= 0; + if (isWatch) { + await tryBuild(resolvedOptions, didBuild); + const watcher = await import('@parcel/watcher'); + watcher.subscribe(config.srcDir, () => tryBuild(resolvedOptions, didBuild)); + } else { + return build(resolvedOptions, didBuild).catch(() => process.exit(1)); + } +} diff --git a/extensions/markdown-language-features/esbuild-browser.ts b/extensions/markdown-language-features/esbuild-browser.ts new file mode 100644 index 00000000000..2c46e390c06 --- /dev/null +++ b/extensions/markdown-language-features/esbuild-browser.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Copy the language server worker main file to the output directory. + */ +async function copyServerWorkerMain(outDir: string): Promise { + const srcPath = path.join(import.meta.dirname, 'node_modules', 'vscode-markdown-languageserver', 'dist', 'browser', 'workerMain.js'); + const destPath = path.join(outDir, 'serverWorkerMain.js'); + await fs.promises.copyFile(srcPath, destPath); +} + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.browser.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + alias: { + 'path': 'path-browserify', + }, + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/esbuild.ts b/extensions/markdown-language-features/esbuild.ts new file mode 100644 index 00000000000..67835c9a1d7 --- /dev/null +++ b/extensions/markdown-language-features/esbuild.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +/** + * Copy the language server worker main file to the output directory. + */ +async function copyServerWorkerMain(outDir: string): Promise { + const srcPath = path.join(import.meta.dirname, 'node_modules', 'vscode-markdown-languageserver', 'dist', 'node', 'workerMain.js'); + const destPath = path.join(outDir, 'serverWorkerMain.js'); + await fs.promises.copyFile(srcPath, destPath); +} + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/extension-browser.webpack.config.js b/extensions/markdown-language-features/extension-browser.webpack.config.js deleted file mode 100644 index 5471319a4c4..00000000000 --- a/extensions/markdown-language-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,27 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import CopyPlugin from 'copy-webpack-plugin'; -import { browser, browserPlugins } from '../shared.webpack.config.mjs'; - -export default browser({ - context: import.meta.dirname, - entry: { - extension: './src/extension.browser.ts' - }, - plugins: [ - ...browserPlugins(import.meta.dirname), // add plugins, don't replace inherited - new CopyPlugin({ - patterns: [ - { - from: './node_modules/vscode-markdown-languageserver/dist/browser/workerMain.js', - to: 'serverWorkerMain.js', - } - ], - }), - ], -}, { - configFile: 'tsconfig.browser.json' -}); diff --git a/extensions/markdown-language-features/extension.webpack.config.js b/extensions/markdown-language-features/extension.webpack.config.js deleted file mode 100644 index 51c9912f9af..00000000000 --- a/extensions/markdown-language-features/extension.webpack.config.js +++ /dev/null @@ -1,28 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import CopyPlugin from 'copy-webpack-plugin'; -import withDefaults, { nodePlugins } from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - }, - plugins: [ - ...nodePlugins(import.meta.dirname), // add plugins, don't replace inherited - new CopyPlugin({ - patterns: [ - { - from: './node_modules/vscode-markdown-languageserver/dist/node/workerMain.js', - to: 'serverWorkerMain.js', - } - ], - }), - ], -}); From efa1a41a18af79e592104d837b665465df86c359 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 10 Feb 2026 21:51:20 +0100 Subject: [PATCH 55/64] modal editor - allow to maximise (#294185) * modal editor - allow to maximise * feedback * feedback --- .../browser/parts/editor/editorCommands.ts | 38 +++++++++++++- .../parts/editor/media/modalEditorPart.css | 4 +- .../browser/parts/editor/modalEditorPart.ts | 50 ++++++++++++++++-- src/vs/workbench/common/contextkeys.ts | 2 + .../editor/common/editorGroupsService.ts | 15 ++++++ .../test/browser/modalEditorGroup.test.ts | 51 +++++++++++++++++++ 6 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index fd15522971e..693918dc2ca 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -27,7 +27,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAccess.js'; import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; -import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; +import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; @@ -107,6 +107,7 @@ export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEdit export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor'; export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain'; +export const TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID = 'workbench.action.toggleModalEditorMaximized'; export const API_OPEN_EDITOR_COMMAND_ID = '_workbench.open'; export const API_OPEN_DIFF_EDITOR_COMMAND_ID = '_workbench.diff'; @@ -1435,6 +1436,39 @@ function registerModalEditorCommands(): void { } }); + registerAction2(class extends Action2 { + constructor() { + super({ + id: TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID, + title: localize2('toggleModalEditorMaximized', 'Maximize Modal Editor'), + category: Categories.View, + f1: true, + precondition: EditorPartModalContext, + icon: Codicon.screenFull, + toggled: { + condition: EditorPartModalMaximizedContext, + icon: Codicon.screenNormal, + title: localize('restoreModalEditorSize', "Restore Modal Editor") + }, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 1 + } + }); + } + run(accessor: ServicesAccessor): void { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + part.toggleMaximized(); + break; + } + } + } + }); + registerAction2(class extends Action2 { constructor() { super({ @@ -1475,6 +1509,8 @@ function isModalEditorPart(obj: unknown): obj is IModalEditorPart { return !!part && typeof part.close === 'function' && typeof part.onWillClose === 'function' + && typeof part.toggleMaximized === 'function' + && typeof part.maximized === 'boolean' && part.windowId === mainWindow.vscodeWindowId; } diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index de3bc1e1f33..19b64fc8c85 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -6,15 +6,15 @@ /** Modal Editor Part: Modal Block */ .monaco-modal-editor-block { position: fixed; - height: 100%; width: 100%; left: 0; - top: 0; /* z-index for modal editors: below dialogs, quick input, context views, hovers but above other things */ z-index: 2000; display: flex; justify-content: center; align-items: center; + /* Never allow content to escape above the title bar */ + overflow: hidden; } .monaco-modal-editor-block.dimmed { diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index f4b26051c5b..6e65299f400 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -22,7 +22,7 @@ import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { EditorPartModalContext } from '../../../common/contextkeys.js'; +import { EditorPartModalContext, EditorPartModalMaximizedContext } from '../../../common/contextkeys.js'; import { Verbosity } from '../../../common/editor.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; @@ -145,10 +145,32 @@ export class ModalEditorPart { })); // Layout the modal editor part - disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, () => { + const layoutModal = () => { const containerDimension = this.layoutService.mainContainerDimension; - const width = Math.min(containerDimension.width * 0.8, 1200); - const height = Math.min(containerDimension.height * 0.8, 800); + const titleBarOffset = this.layoutService.mainContainerOffset.top; + const availableHeight = Math.max(containerDimension.height - titleBarOffset, 0); + + let width: number; + let height: number; + + if (editorPart.maximized) { + const padding = 16; // Keep a small margin around all edges + width = Math.max(containerDimension.width - padding, 0); + height = Math.max(availableHeight - padding, 0); + } else { + const maxWidth = 1200; + const maxHeight = 800; + const targetWidth = containerDimension.width * 0.8; + const targetHeight = availableHeight * 0.8; + width = Math.min(targetWidth, maxWidth, containerDimension.width); + height = Math.min(targetHeight, maxHeight, availableHeight); + } + + height = Math.min(height, availableHeight); // Ensure the modal never exceeds available height (below the title bar) + + // Shift the modal block below the title bar + modalElement.style.top = `${titleBarOffset}px`; + modalElement.style.height = `calc(100% - ${titleBarOffset}px)`; editorPartContainer.style.width = `${width}px`; editorPartContainer.style.height = `${height}px`; @@ -156,7 +178,9 @@ export class ModalEditorPart { const borderSize = 2; // Account for 1px border on all sides and modal header height const headerHeight = 32 + 1 /* border bottom */; editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); - })); + }; + disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); + disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); // Focus the modal editorPartContainer.focus(); @@ -176,6 +200,12 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onWillClose = this._register(new Emitter()); readonly onWillClose = this._onWillClose.event; + private readonly _onDidChangeMaximized = this._register(new Emitter()); + readonly onDidChangeMaximized = this._onDidChangeMaximized.event; + + private _maximized = false; + get maximized(): boolean { return this._maximized; } + private readonly optionsDisposable = this._register(new MutableDisposable()); constructor( @@ -212,10 +242,20 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { this.enforceModalPartOptions(); } + toggleMaximized(): void { + this._maximized = !this._maximized; + + this._onDidChangeMaximized.fire(this._maximized); + } + protected override handleContextKeys(): void { const isModalEditorPartContext = EditorPartModalContext.bindTo(this.scopedContextKeyService); isModalEditorPartContext.set(true); + const isMaximizedContext = EditorPartModalMaximizedContext.bindTo(this.scopedContextKeyService); + isMaximizedContext.set(this._maximized); + this._register(this.onDidChangeMaximized(maximized => isMaximizedContext.set(maximized))); + super.handleContextKeys(); } diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 9d00d682730..20b3b1f804d 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -95,7 +95,9 @@ export const SelectedEditorsInGroupFileOrUntitledResourceContextKey = new RawCon export const EditorPartMultipleEditorGroupsContext = new RawContextKey('editorPartMultipleEditorGroups', false, localize('editorPartMultipleEditorGroups', "Whether there are multiple editor groups opened in an editor part")); export const EditorPartSingleEditorGroupsContext = EditorPartMultipleEditorGroupsContext.toNegated(); export const EditorPartMaximizedEditorGroupContext = new RawContextKey('editorPartMaximizedEditorGroup', false, localize('editorPartEditorGroupMaximized', "Editor Part has a maximized group")); + export const EditorPartModalContext = new RawContextKey('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part")); +export const EditorPartModalMaximizedContext = new RawContextKey('editorPartModalMaximized', false, localize('editorPartModalMaximized', "Whether the modal editor part is maximized")); // Editor Layout Context Keys export const EditorsVisibleContext = new RawContextKey('editorIsOpen', false, localize('editorIsOpen', "Whether an editor is open")); diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index a67469b8609..221b30801c0 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -510,6 +510,21 @@ export interface IModalEditorPart extends IEditorPart { */ readonly onWillClose: Event; + /** + * Whether the modal editor part is currently maximized. + */ + readonly maximized: boolean; + + /** + * Fired when the maximized state changes. + */ + readonly onDidChangeMaximized: Event; + + /** + * Toggle between default and maximized size. + */ + toggleMaximized(): void; + /** * Close this modal editor part after moving all * editors of all groups back to the main editor part diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index c20d6b95d32..fdad2e694ad 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -372,5 +372,56 @@ suite('Modal Editor Group', () => { assert.strictEqual(removedGroupId, modalGroupId); }); + test('modal editor part starts not maximized', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.strictEqual(modalPart.maximized, false); + + modalPart.close(); + }); + + test('modal editor part toggleMaximized toggles state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.strictEqual(modalPart.maximized, false); + + modalPart.toggleMaximized(); + assert.strictEqual(modalPart.maximized, true); + + modalPart.toggleMaximized(); + assert.strictEqual(modalPart.maximized, false); + + modalPart.close(); + }); + + test('modal editor part fires onDidChangeMaximized', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const events: boolean[] = []; + disposables.add(modalPart.onDidChangeMaximized(maximized => events.push(maximized))); + + modalPart.toggleMaximized(); + modalPart.toggleMaximized(); + + assert.deepStrictEqual(events, [true, false]); + + modalPart.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); From fa527f4c80910b5b710aae3d1c8bd95d1b25aaa3 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Feb 2026 13:35:40 -0800 Subject: [PATCH 56/64] Update handling of claude hooks config (#294187) --- .../chat/browser/promptSyntax/hookActions.ts | 38 ++++--- .../common/promptSyntax/hookCompatibility.ts | 13 +++ .../browser/promptSyntax/hookUtils.test.ts | 70 ++++++++++++ .../promptSyntax/hookClaudeCompat.test.ts | 100 ++++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index c3cab3b104c..babfb20a486 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -22,6 +22,8 @@ import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQui import { IFileService } from '../../../../../platform/files/common/files.js'; import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; +import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js'; +import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; @@ -116,15 +118,23 @@ async function addHookToFile( hooksContent = { hooks: {} }; } + // Detect source format from file URI + const sourceFormat = getHookSourceFormat(hookFileUri); + const isClaude = sourceFormat === HookSourceFormat.Claude; + // Detect naming convention from existing keys - const useCopilotCliNamingConvention = usesCopilotCliNaming(hooksContent.hooks); - const hookTypeKeyName = getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); + const useCopilotCliNamingConvention = !isClaude && usesCopilotCliNaming(hooksContent.hooks); + const hookTypeKeyName = isClaude + ? (getClaudeHookTypeName(hookTypeId) ?? hookTypeId) + : getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); // Also check if there's an existing key for this hook type (with either naming) // Find existing key that resolves to the same hook type let existingKeyForType: string | undefined; for (const key of Object.keys(hooksContent.hooks)) { - const resolvedType = resolveCopilotCliHookType(key); + const resolvedType = isClaude + ? resolveClaudeHookType(key) + : resolveCopilotCliHookType(key); if (resolvedType === hookTypeId || key === hookTypeId) { existingKeyForType = key; break; @@ -135,10 +145,7 @@ async function addHookToFile( const keyToUse = existingKeyForType ?? hookTypeKeyName; // Add the new hook entry (append if hook type already exists) - const newHookEntry = { - type: 'command', - command: '' - }; + const newHookEntry = buildNewHookEntry(sourceFormat); let newHookIndex: number; if (!hooksContent.hooks[keyToUse]) { hooksContent.hooks[keyToUse] = [newHookEntry]; @@ -668,14 +675,19 @@ export async function showConfigureHooksQuickPick( return; } + // Detect if new file is a Claude hooks file based on its path + const newFileFormat = getHookSourceFormat(hookFileUri); + const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; + const hookTypeKey = isClaudeNewFile + ? (getClaudeHookTypeName(selectedHookType!.hookType.id as HookType) ?? selectedHookType!.hookType.id) + : selectedHookType!.hookType.id; + const newFileHookEntry = buildNewHookEntry(newFileFormat); + // Create new hook file with the selected hook type const hooksContent = { hooks: { - [selectedHookType!.hookType.id]: [ - { - type: 'command', - command: '' - } + [hookTypeKey]: [ + newFileHookEntry ] } }; @@ -684,7 +696,7 @@ export async function showConfigureHooksQuickPick( await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, selectedHookType!.hookType.id, 0, 'command'); + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); // Open editor with selection store.dispose(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index e3c483d3811..1525bbb59e8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -148,3 +148,16 @@ export function getHookSourceFormatLabel(format: HookSourceFormat): string { return 'GitHub Copilot'; } } + +/** + * Builds a new hook entry object in the appropriate format for the given source format. + * - Copilot format: `{ type: 'command', command: '' }` + * - Claude format: `{ matcher: '', hooks: [{ type: 'command', command: '' }] }` + */ +export function buildNewHookEntry(format: HookSourceFormat): Record { + const commandEntry = { type: 'command', command: '' }; + if (format === HookSourceFormat.Claude) { + return { matcher: '', hooks: [commandEntry] }; + } + return commandEntry; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index 617a4ae433c..d75ce8adbb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; +import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; /** * Helper to extract the selected text from content using a selection range. @@ -652,4 +653,73 @@ suite('hookUtils', () => { }); }); }); + + suite('findHookCommandSelection with buildNewHookEntry', () => { + + test('finds command in Copilot-format generated JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const content = JSON.stringify({ hooks: { SessionStart: [entry] } }, null, '\t'); + const result = findHookCommandSelection(content, 'SessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + }); + + test('finds command in Claude-format generated JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { PreToolUse: [entry] } }, null, '\t'); + const result = findHookCommandSelection(content, 'PreToolUse', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + }); + + test('finds command when appending Claude entry to existing hooks', () => { + const entry1 = buildNewHookEntry(HookSourceFormat.Claude); + const entry2 = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { PreToolUse: [entry1, entry2] } }, null, '\t'); + + const result0 = findHookCommandSelection(content, 'PreToolUse', 0, 'command'); + const result1 = findHookCommandSelection(content, 'PreToolUse', 1, 'command'); + assert.ok(result0); + assert.ok(result1); + assert.strictEqual(getSelectedText(content, result0), ''); + assert.strictEqual(getSelectedText(content, result1), ''); + // Second entry should be on a later line + assert.ok(result1.startLineNumber > result0.startLineNumber); + }); + + test('Claude format JSON has correct structure', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { SubagentStart: [entry] } }, null, '\t'); + const parsed = JSON.parse(content); + assert.deepStrictEqual(parsed, { + hooks: { + SubagentStart: [ + { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + } + ] + } + }); + }); + + test('Copilot format JSON has correct structure', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const content = JSON.stringify({ hooks: { SubagentStart: [entry] } }, null, '\t'); + const parsed = JSON.parse(content); + assert.deepStrictEqual(parsed, { + hooks: { + SubagentStart: [ + { + type: 'command', + command: '' + } + ] + } + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 54a2760c28e..8321a90cf69 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { HookType } from '../../../common/promptSyntax/hookSchema.js'; import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName } from '../../../common/promptSyntax/hookClaudeCompat.js'; +import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookClaudeCompat', () => { @@ -341,3 +342,102 @@ suite('HookClaudeCompat', () => { }); }); }); + +suite('HookSourceFormat', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getHookSourceFormat', () => { + test('detects Claude format for .claude/settings.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.claude/settings.json')), HookSourceFormat.Claude); + }); + + test('detects Claude format for .claude/settings.local.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.claude/settings.local.json')), HookSourceFormat.Claude); + }); + + test('detects Claude format for ~/.claude/settings.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/home/user/.claude/settings.json')), HookSourceFormat.Claude); + }); + + test('returns Copilot format for .github/hooks/hooks.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.github/hooks/hooks.json')), HookSourceFormat.Copilot); + }); + + test('returns Copilot format for arbitrary .json file', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.github/hooks/my-hooks.json')), HookSourceFormat.Copilot); + }); + + test('returns Copilot format for settings.json not inside .claude', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.vscode/settings.json')), HookSourceFormat.Copilot); + }); + }); + + suite('buildNewHookEntry', () => { + test('builds Copilot format entry', () => { + assert.deepStrictEqual(buildNewHookEntry(HookSourceFormat.Copilot), { + type: 'command', + command: '' + }); + }); + + test('builds Claude format entry with matcher wrapper', () => { + assert.deepStrictEqual(buildNewHookEntry(HookSourceFormat.Claude), { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + }); + }); + + test('Claude format entry serializes correctly in JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const hooksContent = { + hooks: { + SubagentStart: [entry] + } + }; + const json = JSON.stringify(hooksContent, null, '\t'); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed.hooks.SubagentStart[0], { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + }); + }); + + test('Copilot format entry serializes correctly in JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const hooksContent = { + hooks: { + SubagentStart: [entry] + } + }; + const json = JSON.stringify(hooksContent, null, '\t'); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed.hooks.SubagentStart[0], { + type: 'command', + command: '' + }); + }); + + test('Claude format round-trips through parseClaudeHooks', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const hooksContent = { + hooks: { + PreToolUse: [entry] + } + }; + + const result = parseClaudeHooks(hooksContent, URI.file('/workspace'), '/home/user'); + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const hooks = result.get(HookType.PreToolUse)!; + assert.strictEqual(hooks.hooks.length, 1); + // Empty command string is falsy and gets omitted by resolveHookCommand + assert.strictEqual(hooks.hooks[0].command, undefined); + }); + }); +}); From 4448228afb3e64516f4cf29a3b2000beb05b02c3 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 10 Feb 2026 22:36:22 +0100 Subject: [PATCH 57/64] Escape closes modal too eagerly when in text editor (fix #294179) (#294195) * Escape closes modal too eagerly when in text editor (fix #294179) * feedback --- .../browser/parts/editor/editorParts.ts | 1 + .../editor/common/editorGroupFinder.ts | 31 ++++++- .../editor/common/editorGroupsService.ts | 5 ++ .../test/browser/modalEditorGroup.test.ts | 81 +++++++++++++++++++ .../test/browser/workbenchTestServices.ts | 2 + 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 71864ffb0c2..61703e493fd 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -155,6 +155,7 @@ export class EditorParts extends MultiWindowParts { diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index 1b93908f2f7..825061a3663 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -8,7 +8,7 @@ import { EditorActivation } from '../../../../platform/editor/common/editor.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { EditorInputWithOptions, isEditorInputWithOptions, IUntypedEditorInput, isEditorInput, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService } from './editorGroupsService.js'; +import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService, IModalEditorPart } from './editorGroupsService.js'; import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; /** @@ -29,12 +29,39 @@ export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOpt const group = doFindGroup(editor, preferredGroup, editorGroupService, configurationService); if (group instanceof Promise) { - return group.then(group => handleGroupActivation(group, editor, preferredGroup, editorGroupService)); + return group.then(group => handleGroupResult(group, editor, preferredGroup, editorGroupService)); + } + + return handleGroupResult(group, editor, preferredGroup, editorGroupService); +} + +function handleGroupResult(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService): [IEditorGroup, EditorActivation | undefined] { + const modalEditorPart = editorGroupService.activeModalEditorPart; + if (modalEditorPart && preferredGroup !== MODAL_GROUP) { + // Only allow to open in modal group if MODAL_GROUP is explicitly requested + group = handleModalEditorPart(group, editor, modalEditorPart, editorGroupService); } return handleGroupActivation(group, editor, preferredGroup, editorGroupService); } +function handleModalEditorPart(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, modalEditorPart: IModalEditorPart, editorGroupService: IEditorGroupsService): IEditorGroup { + const options = editor.options; + + // If the resolved group is part of the modal, redirect + // to the main window active group instead + if (modalEditorPart.groups.some(modalGroup => modalGroup.id === group.id)) { + group = editorGroupService.mainPart.activeGroup; + } + + // Try to close the modal editor part unless preserveFocus is set + if (!options?.preserveFocus) { + modalEditorPart.close(); + } + + return group; +} + function handleGroupActivation(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService): [IEditorGroup, EditorActivation | undefined] { // Resolve editor activation strategy diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 221b30801c0..d7a24923b9e 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -610,6 +610,11 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { */ createModalEditorPart(): Promise; + /** + * The currently active modal editor part, if any. + */ + readonly activeModalEditorPart: IModalEditorPart | undefined; + /** * Returns the instantiation service that is scoped to the * provided editor part. Use this method when building UI diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index fdad2e694ad..f33d165e59f 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -15,6 +15,7 @@ import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEdito import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; +import { findGroup } from '../../common/editorGroupFinder.js'; suite('Modal Editor Group', () => { @@ -372,6 +373,86 @@ suite('Modal Editor Group', () => { assert.strictEqual(removedGroupId, modalGroupId); }); + test('activeModalEditorPart is set when modal is created and cleared on close', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // No modal initially + assert.strictEqual(parts.activeModalEditorPart, undefined); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + assert.strictEqual(parts.activeModalEditorPart, modalPart); + + // Close modal + modalPart.close(); + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + + test('findGroup returns main part group when modal is active and preferredGroup is not MODAL_GROUP', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const mainGroup = parts.mainPart.activeGroup; + + // Create modal and open an editor in it + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // findGroup without MODAL_GROUP should return main part group, not modal group + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + const [group] = instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource }, undefined)); + + assert.strictEqual(group.id, mainGroup.id); + }); + + test('findGroup closes modal when preferredGroup is not MODAL_GROUP and preserveFocus is not set', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.ok(parts.activeModalEditorPart); + + // findGroup without MODAL_GROUP and without preserveFocus should close the modal + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource }, undefined)); + + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + + test('findGroup keeps modal open when preserveFocus is true', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.ok(parts.activeModalEditorPart); + + // findGroup with preserveFocus should keep the modal open + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource, options: { preserveFocus: true } }, undefined)); + + assert.strictEqual(parts.activeModalEditorPart, modalPart); + + modalPart.close(); + }); + test('modal editor part starts not maximized', async () => { const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index a2b918d627a..ba91f4f6ac8 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -924,6 +924,7 @@ export class TestEditorGroupsService implements IEditorGroupsService { enforcePartOptions(options: IEditorPartOptions): IDisposable { return Disposable.None; } readonly mainPart = this; + readonly activeModalEditorPart: IModalEditorPart | undefined = undefined; registerEditorPart(part: any): IDisposable { return Disposable.None; } createAuxiliaryEditorPart(): Promise { throw new Error('Method not implemented.'); } createModalEditorPart(): Promise { throw new Error('Method not implemented.'); } @@ -1639,6 +1640,7 @@ export class TestEditorPart extends MainEditorPart implements IEditorGroupsServi readonly mainPart = this; readonly parts: readonly IEditorPart[] = [this]; + readonly activeModalEditorPart: IModalEditorPart | undefined = undefined; readonly onDidCreateAuxiliaryEditorPart: Event = Event.None; From d99eacc2c1e37731362c16728c80967c2bdc8b6d Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:04:52 -0800 Subject: [PATCH 58/64] fix: apply Copilot feedback (#294235) --- src/vs/workbench/contrib/preferences/browser/settingsLayout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index dc0bd76f057..bc3a037c246 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -174,7 +174,6 @@ export const tocData: ITOCEntry = { { id: 'chat', label: localize('chat', "Chat"), - settings: [], children: [ { id: 'chat/agent', @@ -246,6 +245,7 @@ export const tocData: ITOCEntry = { label: localize('chatContext', "Context"), settings: [ 'chat.detectParticipant.*', + 'chat.experimental.detectParticipant.*', 'chat.implicitContext.*', 'chat.promptFilesLocations', 'chat.instructionsFilesLocations', From 0991a42347e19d171576596e475ef3abd15ec730 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Feb 2026 14:10:55 -0800 Subject: [PATCH 59/64] Add more configuration slash commands (#294231) --- .../contrib/chat/browser/chat.contribution.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 91706e2e422..10e81458395 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1430,6 +1430,46 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'agents', + detail: nls.localize('agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.customagents'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'skills', + detail: nls.localize('skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.skills'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'instructions', + detail: nls.localize('instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.instructions'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'prompts', + detail: nls.localize('prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.prompts'); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'help', detail: '', From 9f6e41537951d91c27a0aea79afc4b4c9bf51184 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 16:20:11 -0600 Subject: [PATCH 60/64] add more tips, make all of them contextual (#294237) fixes #290019 --- .../chat/browser/actions/chatActions.ts | 18 ++ .../contrib/chat/browser/chatTipService.ts | 214 ++++++++++++++++-- .../browser/widget/input/chatInputPart.ts | 8 + .../viewPane/chatContextUsageWidget.ts | 87 ++++--- .../chat/common/actions/chatContextKeys.ts | 2 + .../chat/test/browser/chatTipService.test.ts | 178 ++++++++++++++- 6 files changed, 460 insertions(+), 47 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 24b02b3d54a..980ba420f12 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -809,6 +809,24 @@ export function registerChatActions() { } }); + registerAction2(class ShowContextUsageAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.showContextUsage', + title: localize2('interactiveSession.showContextUsage.label', "Show Context Window Usage"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.enabled, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget ?? (await widgetService.revealWidget()); + widget?.input.showContextUsageDetails(); + } + }); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id)); registerAction2(class extends Action2 { constructor() { diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index a26d065d8a9..5d673cca996 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -19,6 +19,7 @@ import { PromptsType } from '../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -85,6 +86,21 @@ export interface ITipDefinition { * Matches against both mode kind (e.g. 'agent') and mode name (e.g. 'Plan'). */ readonly excludeWhenModesUsed?: string[]; + /** + * Tool IDs that, if ever invoked in this workspace, make this tip ineligible. + * The tip won't be shown if the tool it describes has already been used. + */ + readonly excludeWhenToolsInvoked?: string[]; + /** + * If set, exclude this tip when prompt files of the specified type exist in the workspace. + */ + readonly excludeWhenPromptFilesExist?: { + readonly promptType: PromptsType; + /** Also check for this specific agent instruction file type. */ + readonly agentFileType?: AgentFileType; + /** If true, exclude the tip until the async file check completes. Default: false. */ + readonly excludeUntilChecked?: boolean; + }; } /** @@ -108,10 +124,12 @@ const TIP_CATALOG: ITipDefinition[] = [ { id: 'tip.attachFiles', message: localize('tip.attachFiles', "Tip: Attach files or folders with # to give Copilot more context."), + excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection'], }, { id: 'tip.codeActions', message: localize('tip.codeActions', "Tip: Select code and right-click for Copilot actions in the context menu."), + excludeWhenCommandsExecuted: ['inlineChat.start'], }, { id: 'tip.undoChanges', @@ -126,7 +144,78 @@ const TIP_CATALOG: ITipDefinition[] = [ id: 'tip.customInstructions', 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."), enabledCommands: ['workbench.action.chat.generateInstructions'], - } + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }, + { + id: 'tip.customAgent', + 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."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.command.new.agent'], + excludeWhenCommandsExecuted: ['workbench.command.new.agent'], + excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, + }, + { + id: 'tip.skill', + 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."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.command.new.skill'], + excludeWhenCommandsExecuted: ['workbench.command.new.skill'], + excludeWhenPromptFilesExist: { promptType: PromptsType.skill, excludeUntilChecked: true }, + }, + { + id: 'tip.messageQueueing', + message: localize('tip.messageQueueing', "Tip: You can send follow-up messages while the agent is working. They'll be queued and processed in order."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], + }, + { + id: 'tip.yoloMode', + 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."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), + ), + enabledCommands: ['workbench.action.openSettings'], + }, + { + id: 'tip.mermaid', + message: localize('tip.mermaid', "Tip: Ask the agent to visualize architectures and flows; it can render Mermaid diagrams directly in chat."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }, + { + id: 'tip.githubRepo', + 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."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), + ), + excludeWhenToolsInvoked: ['github-pull-request_doSearch', 'github-pull-request_issue_fetch', 'github-pull-request_formSearchQuery'], + }, + { + id: 'tip.subagents', + message: localize('tip.subagents', "Tip: Ask the agent to implement a plan in parallel; it can delegate work across subagents for faster results."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['runSubagent'], + }, + { + id: 'tip.contextUsage', + 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."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ChatContextKeys.contextUsageHasBeenOpened.negate(), + ChatContextKeys.chatSessionIsEmpty.negate(), + ), + enabledCommands: ['workbench.action.chat.showContextUsage'], + excludeWhenCommandsExecuted: ['workbench.action.chat.showContextUsage'], + }, + { + id: 'tip.sendToNewChat', + 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."), + when: ChatContextKeys.chatSessionIsEmpty.negate(), + enabledCommands: ['workbench.action.chat.sendToNewChat'], + excludeWhenCommandsExecuted: ['workbench.action.chat.sendToNewChat'], + }, ]; /** @@ -138,26 +227,38 @@ export class TipEligibilityTracker extends Disposable { private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands'; private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes'; + private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools'; private readonly _executedCommands: Set; private readonly _usedModes: Set; + private readonly _invokedTools: Set; private readonly _pendingCommands: Set; private readonly _pendingModes: Set; + private readonly _pendingTools: Set; private readonly _commandListener = this._register(new MutableDisposable()); + private readonly _toolListener = this._register(new MutableDisposable()); /** - * Whether agent instruction files exist in the workspace. - * Defaults to `true` (hide the tip) until the async check completes. + * Tip IDs excluded because prompt files of the required type exist in the workspace. + * Tips with `excludeUntilChecked` are pre-added and removed if no files are found. */ - private _hasInstructionFiles = true; + private readonly _excludedByFiles = new Set(); + + /** Tips that have file-based exclusions, kept for re-checks. */ + private readonly _tipsWithFileExclusions: readonly ITipDefinition[]; + + /** Generation counter per tip ID to discard stale async file-check results. */ + private readonly _fileCheckGeneration = new Map(); constructor( tips: readonly ITipDefinition[], @ICommandService commandService: ICommandService, @IStorageService private readonly _storageService: IStorageService, - @IPromptsService promptsService: IPromptsService, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -169,6 +270,9 @@ export class TipEligibilityTracker extends Disposable { const storedModes = this._storageService.get(TipEligibilityTracker._MODES_STORAGE_KEY, StorageScope.WORKSPACE); this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); + const storedTools = this._storageService.get(TipEligibilityTracker._TOOLS_STORAGE_KEY, StorageScope.WORKSPACE); + this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); + // --- Derive what still needs tracking ---------------------------------- this._pendingCommands = new Set(); @@ -189,6 +293,15 @@ export class TipEligibilityTracker extends Disposable { } } + this._pendingTools = new Set(); + for (const tip of tips) { + for (const toolId of tip.excludeWhenToolsInvoked ?? []) { + if (!this._invokedTools.has(toolId)) { + this._pendingTools.add(toolId); + } + } + } + // --- Set up command listener (auto-disposes when all seen) -------------- if (this._pendingCommands.size > 0) { @@ -205,9 +318,40 @@ export class TipEligibilityTracker extends Disposable { }); } - // --- Async file check -------------------------------------------------- + // --- Set up tool listener (auto-disposes when all seen) ----------------- - this._checkForInstructionFiles(promptsService); + if (this._pendingTools.size > 0) { + this._toolListener.value = languageModelToolsService.onDidInvokeTool(e => { + if (this._pendingTools.has(e.toolId)) { + this._invokedTools.add(e.toolId); + this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); + this._pendingTools.delete(e.toolId); + + if (this._pendingTools.size === 0) { + this._toolListener.clear(); + } + } + }); + } + + // --- Async file checks ------------------------------------------------- + + this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + this._checkForPromptFiles(tip); + } + + // Re-check agent file exclusions when custom agents change (covers late discovery) + this._register(this._promptsService.onDidChangeCustomAgents(() => { + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.promptType === PromptsType.agent) { + this._checkForPromptFiles(tip); + } + } + })); } /** @@ -245,6 +389,7 @@ export class TipEligibilityTracker extends Disposable { if (tip.excludeWhenCommandsExecuted) { for (const cmd of tip.excludeWhenCommandsExecuted) { if (this._executedCommands.has(cmd)) { + this._logService.debug('#ChatTips: tip excluded because command was executed', tip.id, cmd); return true; } } @@ -252,26 +397,59 @@ export class TipEligibilityTracker extends Disposable { if (tip.excludeWhenModesUsed) { for (const mode of tip.excludeWhenModesUsed) { if (this._usedModes.has(mode)) { + this._logService.debug('#ChatTips: tip excluded because mode was used', tip.id, mode); return true; } } } - if (tip.id === 'tip.customInstructions' && this._hasInstructionFiles) { + if (tip.excludeWhenToolsInvoked) { + for (const toolId of tip.excludeWhenToolsInvoked) { + if (this._invokedTools.has(toolId)) { + this._logService.debug('#ChatTips: tip excluded because tool was invoked', tip.id, toolId); + return true; + } + } + } + if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { + this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); return true; } return false; } - private async _checkForInstructionFiles(promptsService: IPromptsService): Promise { + private async _checkForPromptFiles(tip: ITipDefinition): Promise { + const config = tip.excludeWhenPromptFilesExist!; + const generation = (this._fileCheckGeneration.get(tip.id) ?? 0) + 1; + this._fileCheckGeneration.set(tip.id, generation); + try { - const [agentInstructions, instructionFiles] = await Promise.all([ - promptsService.listAgentInstructions(CancellationToken.None), - promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + const [promptFiles, agentInstructions] = await Promise.all([ + this._promptsService.listPromptFiles(config.promptType, CancellationToken.None), + config.agentFileType ? this._promptsService.listAgentInstructions(CancellationToken.None) : Promise.resolve([]), ]); - const hasCopilotInstructions = agentInstructions.some(f => f.type === AgentFileType.copilotInstructionsMd); - this._hasInstructionFiles = hasCopilotInstructions || instructionFiles.length > 0; + + // Discard stale result if a newer check was started while we were awaiting + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + + const hasPromptFiles = promptFiles.length > 0; + const hasAgentFile = config.agentFileType + ? agentInstructions.some(f => f.type === config.agentFileType) + : false; + + if (hasPromptFiles || hasAgentFile) { + this._excludedByFiles.add(tip.id); + } else { + this._excludedByFiles.delete(tip.id); + } } catch { - this._hasInstructionFiles = true; + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + if (config.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } } } @@ -413,7 +591,11 @@ export class ChatTipService extends Disposable implements IChatTipService { this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); return false; } - return !this._tracker.isExcluded(tip); + if (this._tracker.isExcluded(tip)) { + return false; + } + this._logService.debug('#ChatTips: tip is eligible', tip.id); + return true; } private _isCopilotEnabled(): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 00b4229ebb0..24ab3f42c01 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1698,6 +1698,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Shows the context usage details popup and focuses it. + * @returns Whether the details were successfully shown. + */ + showContextUsageDetails(): boolean { + return this.contextUsageWidget?.showDetails() ?? false; + } + /** * Updates the context usage widget based on the current model. */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 05f0f2ab499..422e3a3e905 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -13,6 +13,9 @@ import { IObservable, observableValue } from '../../../../../../base/common/obse import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatContextUsageDetails, IChatContextUsageData } from './chatContextUsageDetails.js'; @@ -113,10 +116,16 @@ export class ChatContextUsageWidget extends Disposable { private currentData: IChatContextUsageData | undefined; + private static readonly _OPENED_STORAGE_KEY = 'chat.contextUsage.hasBeenOpened'; + + private readonly _contextUsageOpenedKey: IContextKey; + constructor( @IHoverService private readonly hoverService: IHoverService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -131,49 +140,69 @@ export class ChatContextUsageWidget extends Disposable { this.progressIndicator = new CircularProgressIndicator(); iconContainer.appendChild(this.progressIndicator.domNode); + // Track context usage opened state + this._contextUsageOpenedKey = ChatContextKeys.contextUsageHasBeenOpened.bindTo(this.contextKeyService); + + // Restore persisted state + if (this.storageService.getBoolean(ChatContextUsageWidget._OPENED_STORAGE_KEY, StorageScope.WORKSPACE, false)) { + this._contextUsageOpenedKey.set(true); + } + // Set up hover - will be configured when data is available this.setupHover(); } + /** + * Shows the sticky context usage details hover and records that the user + * has opened it. Returns `true` if the details were shown. + */ + showDetails(): boolean { + const details = this._createDetails(); + if (!details) { + return false; + } + this.hoverService.showInstantHover( + { ...this._hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, + true + ); + this._markOpened(); + return true; + } + + private readonly _hoverOptions: Omit = { + appearance: { showPointer: true, compact: true }, + persistence: { hideOnHover: false }, + trapFocus: true + }; + + private _createDetails(): ChatContextUsageDetails | undefined { + if (!this._isVisible.get() || !this.currentData) { + return undefined; + } + this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); + this._contextUsageDetails.value.update(this.currentData); + return this._contextUsageDetails.value; + } + + private _markOpened(): void { + this._contextUsageOpenedKey.set(true); + this.storageService.store(ChatContextUsageWidget._OPENED_STORAGE_KEY, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + private setupHover(): void { this._hoverDisposable.clear(); const store = new DisposableStore(); this._hoverDisposable.value = store; - const createDetails = (): ChatContextUsageDetails | undefined => { - if (!this._isVisible.get() || !this.currentData) { - return undefined; - } - this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); - this._contextUsageDetails.value.update(this.currentData); - return this._contextUsageDetails.value; - }; - - const hoverOptions: Omit = { - appearance: { showPointer: true, compact: true }, - persistence: { hideOnHover: false }, - trapFocus: true - }; - store.add(this.hoverService.setupDelayedHover(this.domNode, () => ({ - ...hoverOptions, - content: createDetails()?.domNode ?? '' + ...this._hoverOptions, + content: this._createDetails()?.domNode ?? '' }))); - const showStickyHover = () => { - const details = createDetails(); - if (details) { - this.hoverService.showInstantHover( - { ...hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, - true - ); - } - }; - // Show sticky + focused hover on click store.add(addDisposableListener(this.domNode, EventType.CLICK, e => { e.stopPropagation(); - showStickyHover(); + this.showDetails(); })); // Show sticky + focused hover on keyboard activation (Space/Enter) @@ -181,7 +210,7 @@ export class ChatContextUsageWidget extends Disposable { const evt = new StandardKeyboardEvent(e); if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { e.preventDefault(); - showStickyHover(); + this.showDetails(); } })); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a9b4206c5e5..d2d47e5842f 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -120,6 +120,8 @@ export namespace ChatContextKeys { export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + + export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 0c9f6dc9c7e..3ee8fdae9f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -21,6 +21,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; +import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(): boolean { @@ -71,7 +73,9 @@ suite('ChatTipService', () => { instantiationService.stub(IPromptsService, { listAgentInstructions: async () => mockInstructionFiles, listPromptFiles: async () => mockPromptInstructionFiles, + onDidChangeCustomAgents: Event.None, } as Partial as IPromptsService); + instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); }); test('returns a tip for new requests with timestamp after service creation', () => { @@ -229,13 +233,22 @@ suite('ChatTipService', () => { assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); - function createMockPromptsService(agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = []): Partial { + function createMockPromptsService( + agentInstructions: IResolvedAgentFile[] = [], + promptInstructions: IPromptPath[] = [], + options?: { onDidChangeCustomAgents?: Event; listPromptFiles?: (_type: PromptsType) => Promise }, + ): Partial { return { listAgentInstructions: async () => agentInstructions, - listPromptFiles: async (_type: PromptsType) => promptInstructions, + listPromptFiles: options?.listPromptFiles ?? (async (_type: PromptsType) => promptInstructions), + onDidChangeCustomAgents: options?.onDidChangeCustomAgents ?? Event.None, }; } + function createMockToolsService(): MockLanguageModelToolsService { + return testDisposables.add(new MockLanguageModelToolsService()); + } + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { const tip: ITipDefinition = { id: 'tip.undoChanges', @@ -248,6 +261,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before command is executed'); @@ -261,6 +276,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -268,6 +284,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService([{ uri: { path: '/.github/copilot-instructions.md' }, realPath: undefined, type: AgentFileType.copilotInstructionsMd } as IResolvedAgentFile]) as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -280,6 +298,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -287,6 +306,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService([{ uri: { path: '/AGENTS.md' }, realPath: undefined, type: AgentFileType.agentsMd } as IResolvedAgentFile]) as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -299,6 +320,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -306,6 +328,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -318,6 +342,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -325,6 +350,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -348,6 +375,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -372,6 +401,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -393,6 +424,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); @@ -404,6 +437,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted exclusion from workspace storage'); @@ -424,6 +459,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); tracker1.recordCurrentMode(contextKeyService); @@ -435,8 +472,145 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); }); + + test('excludes tip when tracked tool has been invoked', () => { + const mockToolsService = createMockToolsService(); + const tip: ITipDefinition = { + id: 'tip.mermaid', + message: 'test', + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before tool is invoked'); + + mockToolsService.fireOnDidInvokeTool({ toolId: 'renderMermaidDiagram', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after tool is invoked'); + }); + + test('persists tool exclusions to workspace storage across tracker instances', () => { + const mockToolsService = createMockToolsService(); + const tip: ITipDefinition = { + id: 'tip.subagents', + message: 'test', + excludeWhenToolsInvoked: ['runSubagent'], + }; + + const tracker1 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + mockToolsService.fireOnDidInvokeTool({ toolId: 'runSubagent', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); + assert.strictEqual(tracker1.isExcluded(tip), true); + + // Second tracker reads from storage — should be excluded immediately + const tracker2 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted tool exclusion from workspace storage'); + }); + + test('excludes tip.skill when skill files exist in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.skill', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [{ uri: URI.file('/.github/skills/my-skill.skill.md'), storage: PromptsStorage.local, type: PromptsType.skill }]) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when skill files exist'); + }); + + test('does not exclude tip.skill when no skill files exist', async () => { + const tip: ITipDefinition = { + id: 'tip.skill', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); + }); + + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { + const agentChangeEmitter = testDisposables.add(new Emitter()); + let agentFiles: IPromptPath[] = []; + + const tip: ITipDefinition = { + id: 'tip.customAgent', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [], { + onDidChangeCustomAgents: agentChangeEmitter.event, + listPromptFiles: async () => agentFiles, + }) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Initial check: no agent files, but excludeUntilChecked means excluded first + await new Promise(r => setTimeout(r, 0)); + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded after initial check finds no files'); + + // Simulate agent files appearing + agentFiles = [{ uri: URI.file('/.github/agents/my-agent.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent }]; + agentChangeEmitter.fire(); + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after onDidChangeCustomAgents fires and agent files exist'); + }); }); From b3c0c092b332eb8f43fd1a31e2eeb4d4cc15ce62 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:24:13 -0800 Subject: [PATCH 61/64] searchTerms -> keywords --- .../platform/configuration/common/configurationRegistry.ts | 4 ++-- src/vs/workbench/api/common/configurationExtensionPoint.ts | 4 ++-- .../contrib/preferences/browser/preferencesSearch.ts | 6 +++--- src/vs/workbench/services/preferences/common/preferences.ts | 2 +- .../services/preferences/common/preferencesModels.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index c26fc939188..f035cdec47c 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -201,9 +201,9 @@ export interface IConfigurationPropertySchema extends IJSONSchema { enumItemLabels?: string[]; /** - * Optional terms used for search purposes. + * Optional keywords used for search purposes. */ - searchTerms?: string[]; + keywords?: string[]; /** * When specified, controls the presentation format of string settings. diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 6f777af406a..46304274ef0 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -116,12 +116,12 @@ const configurationEntrySchema: IJSONSchema = { type: 'boolean', description: nls.localize('scope.ignoreSync', 'When enabled, Settings Sync will not sync the user value of this configuration by default.') }, - searchTerms: { + keywords: { type: 'array', items: { type: 'string' }, - description: nls.localize('scope.searchTerms', 'A list of additional search terms that help users find this setting in the Settings editor. These are not shown to the user.') + description: nls.localize('scope.keywords', 'A list of keywords that help users find this setting in the Settings editor. These are not shown to the user.') }, tags: { type: 'array', diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 0cfcac7318d..99fd9647231 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -242,9 +242,9 @@ export class SettingMatches { // Search the description if we found non-contiguous key matches at best. const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel; if (this.searchDescription && !hasContiguousKeyMatchTypes) { - // Search the description lines and any additional search terms. - const searchableLines = setting.searchTerms?.length - ? [...setting.description, setting.searchTerms.join(' ')] + // Search the description lines and any additional keywords. + const searchableLines = setting.keywords?.length + ? [...setting.description, setting.keywords.join(' ')] : setting.description; for (const word of queryWords) { for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) { diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 08cb72355db..106103bdd9f 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -64,7 +64,7 @@ export interface ISetting { value: any; valueRange: IRange; description: string[]; - searchTerms?: string[]; + keywords?: string[]; descriptionIsMarkdown?: boolean; descriptionRanges: IRange[]; overrides?: ISetting[]; diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 79fc7bc33c5..a6e78cc1fa0 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -723,7 +723,7 @@ export class DefaultSettings extends Disposable { value, description: descriptionLines, descriptionIsMarkdown: !!prop.markdownDescription, - searchTerms: prop.searchTerms, + keywords: prop.keywords, range: nullRange, keyRange: nullRange, valueRange: nullRange, From 11bbecdc9c8ddb2b67e42da1b360f86b73e1e80d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 10 Feb 2026 22:59:11 +0000 Subject: [PATCH 62/64] Move hook execution to extension (#294215) * Refactor hook execution * Fix compilation: add IExtHostHooks import, remove unused IHookResult, inline ChatRequestHooks type * Move hooks property to chatHooks proposal, sync DTS * cleanup * Remove dead hook execution code: proxy, RPC, output channel, progress events All hook execution now happens in the extension via NodeHookExecutor. HooksExecutionService is now a pure registry (registerHooks/getHooksForSession). Removed: - executeHook, setProxy, onDidHookProgress from service - IHooksExecutionProxy, IHookProgressEvent, HookAbortError, formatHookErrorMessage - hooksCommandTypes.ts, hooksTypes.ts (dead type files) - mainThreadHooks proxy setup - extHostHooksNode., extHostHooksWorker. - ExtHostHooksShape. protocol - IExtHostHooks DI registrations - ChatHooksProgressContribution - All associated test files * Remove HooksExecutionService entirely The service was only a registry for session hooks, but hooks are already passed directly on the chat request DTO. The registerHooks/getHooksForSession pattern was redundant. * Restore modelName support in chatSubagentContentPart that was accidentally removed during merge * Revert unrelated tabIndex change on chatSubagentContentPart * Remove empty hooks ext host infrastructure Delete IExtHostHooks, NodeExtHostHooks, WorkerExtHostHooks, MainThreadHooks, ExtHostHooksShape, MainThreadHooksShape - all were empty stubs after hook execution moved to extension. * Remove mainThreadHooks import from extensionHost.contribution * Fix DTS comments: env and timeoutSec are values, not implementation promises --- .../common/extensionsApiProposals.ts | 2 +- .../api/browser/extensionHost.contribution.ts | 1 - .../workbench/api/browser/mainThreadHooks.ts | 43 -- .../workbench/api/common/extHost.api.impl.ts | 7 - .../workbench/api/common/extHost.protocol.ts | 15 - src/vs/workbench/api/common/extHostHooks.ts | 21 - .../api/common/extHostTypeConverters.ts | 44 +- .../api/node/extHost.node.services.ts | 3 - src/vs/workbench/api/node/extHostHooksNode.ts | 196 -------- .../api/test/node/extHostHooks.test.ts | 137 ------ .../api/worker/extHost.worker.services.ts | 3 - .../api/worker/extHostHooksWorker.ts | 50 -- .../contrib/chat/browser/chat.contribution.ts | 34 -- .../common/chatService/chatServiceImpl.ts | 6 - .../chat/common/hooks/hooksCommandTypes.ts | 67 --- .../common/hooks/hooksExecutionService.ts | 465 ------------------ .../contrib/chat/common/hooks/hooksTypes.ts | 56 --- .../chatEditing/chatEditingService.test.ts | 4 - .../tools/languageModelToolsService.test.ts | 37 +- .../test/common/hooksExecutionService.test.ts | 402 --------------- src/vscode-dts/vscode.proposed.chatHooks.d.ts | 46 +- 21 files changed, 67 insertions(+), 1572 deletions(-) delete mode 100644 src/vs/workbench/api/browser/mainThreadHooks.ts delete mode 100644 src/vs/workbench/api/common/extHostHooks.ts delete mode 100644 src/vs/workbench/api/node/extHostHooksNode.ts delete mode 100644 src/vs/workbench/api/test/node/extHostHooks.test.ts delete mode 100644 src/vs/workbench/api/worker/extHostHooksWorker.ts delete mode 100644 src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts delete mode 100644 src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts delete mode 100644 src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts delete mode 100644 src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index fa429fbf424..abc94186d7f 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', - version: 3 + version: 5 }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 20eab0ab271..ac03a724943 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -98,7 +98,6 @@ import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; -import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts deleted file mode 100644 index d76cbc2a46c..00000000000 --- a/src/vs/workbench/api/browser/mainThreadHooks.ts +++ /dev/null @@ -1,43 +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 { URI, UriComponents } from '../../../base/common/uri.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; - -@extHostNamedCustomer(MainContext.MainThreadHooks) -export class MainThreadHooks extends Disposable implements MainThreadHooksShape { - - constructor( - extHostContext: IExtHostContext, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, - ) { - super(); - const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); - - const proxy: IHooksExecutionProxy = { - runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise => { - const result = await extHostProxy.$runHookCommand(hookCommand, input, token); - return { - kind: result.kind as HookCommandResultKind, - result: result.result - }; - } - }; - - this._hooksExecutionService.setProxy(proxy); - } - - async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise { - const uri = URI.revive(sessionResource); - return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token }); - } -} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ef3f6a731dd..d41aab9b5d6 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -65,7 +65,6 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js'; import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostInitDataService } from './extHostInitDataService.js'; -import { IExtHostHooks } from './extHostHooks.js'; import { ExtHostInteractive } from './extHostInteractive.js'; import { ExtHostLabelService } from './extHostLabelService.js'; import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js'; @@ -245,7 +244,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); - rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -257,7 +255,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); - const extHostHooks = accessor.get(IExtHostHooks); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1661,10 +1658,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, - async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise { - checkProposedApiEnabled(extension, 'chatHooks'); - return extHostHooks.executeHook(hookType, options, token); - }, }; // namespace: lm diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fff9a8d8845..c1a9e98276b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,9 +99,6 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3234,12 +3231,6 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } -export type IHookCommandDto = Dto; - -export interface ExtHostHooksShape { - $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3295,10 +3286,6 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } -export interface MainThreadHooksShape extends IDisposable { - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3535,7 +3522,6 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), - MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3615,7 +3601,6 @@ export const ExtHostContext = { ExtHostMeteredConnection: createProxyIdentifier('ExtHostMeteredConnection'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), - ExtHostHooks: createProxyIdentifier('ExtHostHooks'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), }; diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts deleted file mode 100644 index d03d803c47c..00000000000 --- a/src/vs/workbench/api/common/extHostHooks.ts +++ /dev/null @@ -1,21 +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 type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostHooksShape } from './extHost.protocol.js'; - -export const IExtHostHooks = createDecorator('IExtHostHooks'); - -export interface IChatHookExecutionOptions { - readonly input?: unknown; - readonly toolInvocationToken: unknown; -} - -export interface IExtHostHooks extends ExtHostHooksShape { - executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; -} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 885302dcb9b..3bddf605a30 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -16,6 +16,7 @@ import { parse, revive } from '../../../base/common/marshalling.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { Mimes } from '../../../base/common/mime.js'; import { cloneAndChange } from '../../../base/common/objects.js'; +import { OS } from '../../../base/common/platform.js'; import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js'; import { basename } from '../../../base/common/resources.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -45,7 +46,7 @@ import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCom import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; +import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -3437,6 +3438,7 @@ export namespace ChatAgentRequest { subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, hasHooksEnabled: request.hasHooksEnabled ?? false, + hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3464,6 +3466,8 @@ export namespace ChatAgentRequest { delete (requestWithAllProps as any).parentRequestId; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).hasHooksEnabled; + // eslint-disable-next-line local/code-no-any-casts + delete (requestWithAllProps as any).hooks; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { @@ -4082,13 +4086,39 @@ export namespace SourceControlInputBoxValidationType { } } -export namespace ChatHookResult { - export function to(result: IHookResult): vscode.ChatHookResult { +export namespace ChatRequestHooksConverter { + export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks { + const result: Record = {}; + for (const [hookType, commands] of Object.entries(hooks)) { + if (!commands || commands.length === 0) { + continue; + } + const converted: vscode.ChatHookCommand[] = []; + for (const cmd of commands) { + const resolved = ChatHookCommand.to(cmd); + if (resolved) { + converted.push(resolved); + } + } + if (converted.length > 0) { + result[hookType] = converted; + } + } + return result; + } +} + +export namespace ChatHookCommand { + export function to(hook: IHookCommand): vscode.ChatHookCommand | undefined { + const command = resolveEffectiveCommand(hook, OS); + if (!command) { + return undefined; + } return { - resultKind: result.resultKind, - stopReason: result.stopReason, - warningMessage: result.warningMessage, - output: result.output, + command, + cwd: hook.cwd, + env: hook.env, + timeoutSec: hook.timeoutSec, }; } } diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 5f52766f40a..55acd8bd9c1 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,8 +31,6 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; -import { NodeExtHostHooks } from './extHostHooksNode.js'; // ######################################################################### // ### ### @@ -55,4 +53,3 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); -registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts deleted file mode 100644 index 1b00ae2a271..00000000000 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ /dev/null @@ -1,196 +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 type * as vscode from 'vscode'; -import { spawn } from 'child_process'; -import { homedir } from 'os'; -import * as nls from '../../../nls.js'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { OS } from '../../../base/common/platform.js'; -import { URI, isUriComponents } from '../../../base/common/uri.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; - -const SIGKILL_DELAY_MS = 5000; - -export class NodeExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - this._logService.error('[NodeExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise { - this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); - - try { - return await this._executeCommand(hookCommand, input, token); - } catch (err) { - return { - kind: HookCommandResultKind.Error, - result: err instanceof Error ? err.message : String(err) - }; - } - } - - private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise { - const home = homedir(); - const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; - const cwd = cwdUri ? cwdUri.fsPath : home; - - // Resolve the effective command for the current platform - // This applies windows/linux/osx overrides and falls back to command - const effectiveCommand = resolveEffectiveCommand(hook as Parameters[0], OS); - if (!effectiveCommand) { - return Promise.resolve({ - kind: HookCommandResultKind.NonBlockingError, - result: nls.localize('noCommandForPlatform', "No command specified for the current platform") - }); - } - - // Execute the command, preserving legacy behavior for explicit shell types: - // - powershell source: run through PowerShell so PowerShell-specific commands work - // - bash source: run through bash so bash-specific commands work - // - otherwise: use default shell via spawn with shell: true - const commandSource = getEffectiveCommandSource(hook as Parameters[0], OS); - let shellExecutable: string | undefined; - let shellArgs: string[] | undefined; - - if (commandSource === 'powershell') { - shellExecutable = 'powershell.exe'; - shellArgs = ['-Command', effectiveCommand]; - } else if (commandSource === 'bash') { - shellExecutable = 'bash'; - shellArgs = ['-c', effectiveCommand]; - } - - const child = shellExecutable && shellArgs - ? spawn(shellExecutable, shellArgs, { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - }) - : spawn(effectiveCommand, [], { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - shell: true, - }); - - return new Promise((resolve, reject) => { - const stdout: string[] = []; - const stderr: string[] = []; - let exitCode: number | null = null; - let exited = false; - - const disposables = new DisposableStore(); - const sigkillTimeout = disposables.add(new MutableDisposable()); - - const killWithEscalation = () => { - if (exited) { - return; - } - child.kill('SIGTERM'); - sigkillTimeout.value = disposableTimeout(() => { - if (!exited) { - child.kill('SIGKILL'); - } - }, SIGKILL_DELAY_MS); - }; - - const cleanup = () => { - exited = true; - disposables.dispose(); - }; - - // Collect output - child.stdout.on('data', data => stdout.push(data.toString())); - child.stderr.on('data', data => stderr.push(data.toString())); - - // Set up timeout (default 30 seconds) - disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000)); - - // Set up cancellation - if (token) { - disposables.add(token.onCancellationRequested(killWithEscalation)); - } - - // Write input to stdin - if (input !== undefined && input !== null) { - try { - // Use a replacer to convert URI values to filesystem paths. - // URIs arrive as UriComponents objects via the RPC boundary. - child.stdin.write(JSON.stringify(input, (_key, value) => { - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - })); - } catch { - // Ignore stdin write errors - } - } - child.stdin.end(); - - // Capture exit code - child.on('exit', code => { exitCode = code; }); - - // Resolve on close (after streams flush) - child.on('close', () => { - cleanup(); - const code = exitCode ?? 1; - const stdoutStr = stdout.join(''); - const stderrStr = stderr.join(''); - - if (code === 0) { - // Success - try to parse stdout as JSON, otherwise return as string - let result: string | object = stdoutStr; - try { - result = JSON.parse(stdoutStr); - } catch { - // Keep as string if not valid JSON - } - resolve({ kind: HookCommandResultKind.Success, result }); - } else if (code === 2) { - // Blocking error - show stderr to model and stop processing - resolve({ kind: HookCommandResultKind.Error, result: stderrStr }); - } else { - // Non-blocking error - show stderr to user only - resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr }); - } - }); - - child.on('error', err => { - cleanup(); - reject(err); - }); - }); - } -} diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts deleted file mode 100644 index f398cffd3f5..00000000000 --- a/src/vs/workbench/api/test/node/extHostHooks.test.ts +++ /dev/null @@ -1,137 +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 { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../platform/log/common/log.js'; -import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; -import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js'; -import { HookCommandResultKind } from '../../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../../contrib/chat/common/hooks/hooksTypes.js'; -import { IExtHostRpcService } from '../../common/extHostRpcService.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; - -function createHookCommandDto(command: string, options?: Partial>): IHookCommandDto { - return { - type: 'command', - command, - ...options, - }; -} - -function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService { - return { - _serviceBrand: undefined, - getProxy(): T { - return mainThreadProxy as unknown as T; - }, - set(_identifier: unknown, instance: R): R { - return instance; - }, - dispose(): void { }, - assertRegistered(): void { }, - drain(): Promise { return Promise.resolve(); }, - } as IExtHostRpcService; -} - -suite.skip('ExtHostHooks', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - let hooksService: NodeExtHostHooks; - - setup(() => { - const mockMainThreadProxy: MainThreadHooksShape = { - $executeHook: async (): Promise => { - return []; - }, - dispose: () => { } - }; - - const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy); - hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService()); - }); - - test('$runHookCommand runs command and returns success result', async () => { - const hookCommand = createHookCommandDto('echo "hello world"'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'hello world'); - }); - - test('$runHookCommand parses JSON output', async () => { - const hookCommand = createHookCommandDto('echo \'{"key": "value"}\''); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, { key: 'value' }); - }); - - test('$runHookCommand returns non-blocking error for exit code 1', async () => { - const hookCommand = createHookCommandDto('exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand returns blocking error for exit code 2', async () => { - const hookCommand = createHookCommandDto('exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - }); - - test('$runHookCommand captures stderr on non-blocking error', async () => { - const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - assert.strictEqual((result.result as string).trim(), 'error message'); - }); - - test('$runHookCommand captures stderr on blocking error', async () => { - const hookCommand = createHookCommandDto('echo "blocking error" >&2 && exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - assert.strictEqual((result.result as string).trim(), 'blocking error'); - }); - - test('$runHookCommand passes input to stdin as JSON', async () => { - const hookCommand = createHookCommandDto('cat'); - const input = { tool: 'bash', args: { command: 'ls' } }; - const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, input); - }); - - test('$runHookCommand returns non-blocking error for invalid command', async () => { - const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - // Invalid commands typically return non-zero exit codes (127 for command not found) - // which are treated as non-blocking errors unless it's exit code 2 - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand uses custom environment variables', async () => { - const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'custom_value'); - }); - - test('$runHookCommand uses custom cwd', async () => { - const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - // The result should contain /tmp or /private/tmp (macOS symlink) - assert.ok((result.result as string).includes('tmp')); - }); -}); diff --git a/src/vs/workbench/api/worker/extHost.worker.services.ts b/src/vs/workbench/api/worker/extHost.worker.services.ts index 14ec71b80e9..d6055bcf0f6 100644 --- a/src/vs/workbench/api/worker/extHost.worker.services.ts +++ b/src/vs/workbench/api/worker/extHost.worker.services.ts @@ -8,12 +8,10 @@ import { InstantiationType, registerSingleton } from '../../../platform/instanti import { ILogService } from '../../../platform/log/common/log.js'; import { ExtHostAuthentication, IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { IExtHostExtensionService } from '../common/extHostExtensionService.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; import { ExtHostLogService } from '../common/extHostLogService.js'; import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { ExtHostExtensionService } from './extHostExtensionService.js'; -import { WorkerExtHostHooks } from './extHostHooksWorker.js'; // ######################################################################### // ### ### @@ -26,4 +24,3 @@ registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationTy registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true)); -registerSingleton(IExtHostHooks, WorkerExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/worker/extHostHooksWorker.ts b/src/vs/workbench/api/worker/extHostHooksWorker.ts deleted file mode 100644 index 3bd7fcf6edf..00000000000 --- a/src/vs/workbench/api/worker/extHostHooksWorker.ts +++ /dev/null @@ -1,50 +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 type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; - -export class WorkerExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - this._logService.error('[WorkerExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(_hookCommand: IHookCommandDto, _input: unknown, _token: CancellationToken): Promise { - this._logService.debug('[WorkerExtHostHooks] Hook commands are not supported in web worker context'); - - // Web worker cannot run shell commands - return an error - return { - kind: HookCommandResultKind.Error, - result: 'Hook commands are not supported in web worker context' - }; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 10e81458395..f5d3a047e54 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,7 +52,6 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { HooksExecutionService, IHooksExecutionService } from '../common/hooks/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; @@ -1191,37 +1190,6 @@ class ChatResolverContribution extends Disposable { } } -class ChatHooksProgressContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatHooksProgress'; - - constructor( - @IChatService private readonly chatService: IChatService, - @IHooksExecutionService hooksExecutionService: IHooksExecutionService, - ) { - super(); - - this._register(hooksExecutionService.onDidHookProgress(event => { - const model = this.chatService.getSession(event.sessionResource); - if (!model) { - return; - } - - const request = model.getRequests().at(-1); - if (!request) { - return; - } - - this.chatService.appendProgress(request, { - kind: 'hook', - hookType: event.hookType, - stopReason: event.stopReason, - systemMessage: event.systemMessage, - }); - })); - } -} - class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatAgentSetting'; @@ -1528,7 +1496,6 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatHooksProgressContribution.ID, ChatHooksProgressContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); @@ -1609,7 +1576,6 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); -registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 838008c642b..b55b824cd7d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -51,7 +51,6 @@ import { ILanguageModelToolsService } from '../tools/languageModelToolsService.j import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; -import { IHooksExecutionService } from '../hooks/hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; @@ -156,7 +155,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, - @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, ) { super(); @@ -911,10 +909,6 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } - if (collectedHooks) { - store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks)); - } - const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts deleted file mode 100644 index 1b31c0fafd5..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * External hook types - types that cross the process boundary to/from spawned hook commands. - * - * "External" means these types define the contract between VS Code and the external hook - * command process. - * - * Internal types (in hooksTypes.ts) are used within VS Code. - */ - -import { URI } from '../../../../../base/common/uri.js'; - -//#region Common Hook Types - -/** - * Common properties added to all hook command inputs. - */ -export interface IHookCommandInput { - readonly timestamp: string; - readonly cwd: URI; - readonly sessionId: string; - readonly hookEventName: string; - readonly transcript_path?: URI; -} - -/** - * Common output fields that can be present in any hook command result. - * These fields control execution flow and user feedback. - */ -export interface IHookCommandOutput { - /** - * If set, stops processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Message shown to the user. - */ - readonly systemMessage?: string; -} - -export const enum HookCommandResultKind { - Success = 1, - /** Blocking error - shown to model */ - Error = 2, - /** Non-blocking error - shown to user only */ - NonBlockingError = 3 -} - -/** - * Raw result from spawning a hook command. - * This is the low-level result before semantic processing. - */ -export interface IHookCommandResult { - readonly kind: HookCommandResultKind; - /** - * For success, this is stdout (parsed as JSON if valid, otherwise string). - * For errors, this is stderr. - */ - readonly result: string | object; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts deleted file mode 100644 index 5e6f02e05b4..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ /dev/null @@ -1,465 +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 { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { URI, isUriComponents } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; -import { - HookCommandResultKind, - IHookCommandInput, - IHookCommandResult, -} from './hooksCommandTypes.js'; -import { - commonHookOutputValidator, - IHookResult, -} from './hooksTypes.js'; - -export const hooksOutputChannelId = 'hooksExecution'; -const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); - -export interface IHooksExecutionOptions { - readonly input?: unknown; - readonly token?: CancellationToken; -} - -export interface IHookExecutedEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly input: unknown; - readonly results: readonly IHookResult[]; - readonly durationMs: number; -} - -/** - * Event fired when a hook produces progress that should be shown to the user. - */ -export interface IHookProgressEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly stopReason?: string; - readonly systemMessage?: string; -} - -/** - * Callback interface for hook execution proxies. - * MainThreadHooks implements this to forward calls to the extension host. - */ -export interface IHooksExecutionProxy { - runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; -} - -export const IHooksExecutionService = createDecorator('hooksExecutionService'); - -export interface IHooksExecutionService { - _serviceBrand: undefined; - - /** - * Fires when a hook has finished executing. - */ - readonly onDidExecuteHook: Event; - - /** - * Fires when a hook produces progress (warning or stop) that should be shown to the user. - */ - readonly onDidHookProgress: Event; - - /** - * Called by mainThreadHooks when extension host is ready - */ - setProxy(proxy: IHooksExecutionProxy): void; - - /** - * Register hooks for a session. Returns a disposable that unregisters them. - */ - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; - - /** - * Get hooks registered for a session. - */ - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; - - /** - * Execute hooks of the given type for the given session - */ - executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; -} - -/** - * Keys that should be redacted when logging hook input. - */ -const redactedInputKeys = ['toolArgs']; - -export class HooksExecutionService extends Disposable implements IHooksExecutionService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidExecuteHook = this._register(new Emitter()); - readonly onDidExecuteHook: Event = this._onDidExecuteHook.event; - - private readonly _onDidHookProgress = this._register(new Emitter()); - readonly onDidHookProgress: Event = this._onDidHookProgress.event; - - private _proxy: IHooksExecutionProxy | undefined; - private readonly _sessionHooks = new Map(); - /** Stored transcript path per session (keyed by session URI string). */ - private readonly _sessionTranscriptPaths = new Map(); - private _channelRegistered = false; - private _requestCounter = 0; - - constructor( - @ILogService private readonly _logService: ILogService, - @IOutputService private readonly _outputService: IOutputService, - ) { - super(); - } - - setProxy(proxy: IHooksExecutionProxy): void { - this._proxy = proxy; - } - - private _ensureOutputChannel(): void { - if (this._channelRegistered) { - return; - } - Registry.as(Extensions.OutputChannels).registerChannel({ - id: hooksOutputChannelId, - label: hooksOutputChannelLabel, - log: false - }); - this._channelRegistered = true; - } - - private _log(requestId: number, hookType: HookTypeValue, message: string): void { - this._ensureOutputChannel(); - const channel = this._outputService.getChannel(hooksOutputChannelId); - if (channel) { - channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`); - } - } - - private _redactForLogging(input: object): object { - const result: Record = { ...input }; - for (const key of redactedInputKeys) { - if (Object.hasOwn(result, key)) { - result[key] = '...'; - } - } - return result; - } - - /** - * JSON.stringify replacer that converts URI / UriComponents values to their string form. - */ - private readonly _uriReplacer = (_key: string, value: unknown): unknown => { - if (URI.isUri(value)) { - return value.fsPath; - } - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - }; - - private async _runSingleHook( - requestId: number, - hookType: HookTypeValue, - hookCommand: IHookCommand, - sessionResource: URI, - callerInput: unknown, - transcriptPath: URI | undefined, - token: CancellationToken - ): Promise { - // Build the common hook input properties. - // URI values are kept as URI objects through the RPC boundary, and converted - // to filesystem paths on the extension host side during JSON serialization. - const commonInput: IHookCommandInput = { - timestamp: new Date().toISOString(), - cwd: hookCommand.cwd ?? URI.file(''), - sessionId: sessionResource.toString(), - hookEventName: hookType, - ...(transcriptPath ? { transcript_path: transcriptPath } : undefined), - }; - - // Merge common properties with caller-specific input - const fullInput = !!callerInput && typeof callerInput === 'object' - ? { ...commonInput, ...callerInput } - : commonInput; - - const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer); - this._log(requestId, hookType, `Running: ${hookCommandJson}`); - const inputForLog = this._redactForLogging(fullInput); - this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`); - - const sw = StopWatch.create(); - try { - const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token); - const result = this._toInternalResult(commandResult); - this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed())); - return result; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); - // Proxy errors (e.g., process spawn failure) are treated as warnings - return { - resultKind: 'warning', - output: undefined, - warningMessage: errMessage, - }; - } - } - - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { - switch (commandResult.kind) { - case HookCommandResultKind.Error: { - // Exit code 2 - stop processing with message shown to user (not model) - // Equivalent to continue=false with stopReason=stderr - const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'error', - stopReason: message, - output: undefined, - }; - } - case HookCommandResultKind.NonBlockingError: { - // Non-blocking error - shown to user only as warning - const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'warning', - output: undefined, - warningMessage: errorMessage, - }; - } - case HookCommandResultKind.Success: { - // For string results, no common fields to extract - if (typeof commandResult.result !== 'object') { - return { - resultKind: 'success', - output: commandResult.result, - }; - } - - // Extract and validate common fields - const validationResult = commonHookOutputValidator.validate(commandResult.result); - const commonFields = validationResult.error ? {} : validationResult.content; - - // Extract only known hook-specific fields for output - const resultObj = commandResult.result as Record; - const hookOutput = this._extractHookSpecificOutput(resultObj); - - // Handle continue field: when false, stopReason is effective - // stopReason takes precedence if both are set - let stopReason = commonFields.stopReason; - if (commonFields.continue === false && !stopReason) { - stopReason = ''; // Empty string signals stop without a specific reason - } - - return { - resultKind: 'success', - stopReason, - warningMessage: commonFields.systemMessage, - output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, - }; - } - default: { - // Unexpected result kind - treat as warning - return { - resultKind: 'warning', - warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, - output: undefined, - }; - } - } - } - - /** - * Extract hook-specific output fields, excluding common fields. - */ - private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['continue', 'stopReason', 'systemMessage']); - const output: Record = {}; - for (const [key, value] of Object.entries(result)) { - if (value !== undefined && !commonFields.has(key)) { - output[key] = value; - } - } - - return output; - } - - private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void { - const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success' - : result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError' - : 'Error'; - const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]'; - if (hasOutput) { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`); - this._log(requestId, hookType, `Output: ${resultStr}`); - } else { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`); - } - } - - /** - * Extract `transcript_path` from hook input if present. - * The caller (e.g. SessionStart) may include it as a URI in the input object. - */ - private _extractTranscriptPath(input: unknown): URI | undefined { - if (typeof input !== 'object' || input === null) { - return undefined; - } - const transcriptPath = (input as Record)['transcriptPath']; - if (URI.isUri(transcriptPath)) { - return transcriptPath; - } - if (isUriComponents(transcriptPath)) { - return URI.revive(transcriptPath); - } - return undefined; - } - - /** - * Emit a hook progress event to show warnings or stop reasons to the user. - */ - private _emitHookProgress(hookType: HookTypeValue, sessionResource: URI, stopReason?: string, systemMessage?: string): void { - this._onDidHookProgress.fire({ - hookType, - sessionResource, - stopReason, - systemMessage, - }); - } - - /** - * Collect all warning messages from hook results and emit them as a single aggregated progress event. - * Uses numbered list formatting when there are multiple warnings. - */ - private _emitAggregatedWarnings(hookType: HookTypeValue, sessionResource: URI, results: readonly IHookResult[]): void { - const warnings = results - .filter(r => r.warningMessage !== undefined) - .map(r => r.warningMessage!); - - if (warnings.length > 0) { - const message = warnings.length === 1 - ? warnings[0] - : warnings.map((w, i) => `${i + 1}. ${w}`).join('\n'); - this._emitHookProgress(hookType, sessionResource, undefined, message); - } - } - - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { - const key = sessionResource.toString(); - this._sessionHooks.set(key, hooks); - return toDisposable(() => { - this._sessionHooks.delete(key); - this._sessionTranscriptPaths.delete(key); - }); - } - - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { - return this._sessionHooks.get(sessionResource.toString()); - } - - async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { - const sw = StopWatch.create(); - const results: IHookResult[] = []; - - try { - if (!this._proxy) { - return results; - } - - const sessionKey = sessionResource.toString(); - - // Extract and store transcript_path from input when present (e.g. SessionStart) - const inputTranscriptPath = this._extractTranscriptPath(options?.input); - if (inputTranscriptPath) { - this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath); - } - - const hooks = this.getHooksForSession(sessionResource); - if (!hooks) { - return results; - } - - const hookCommands = hooks[hookType]; - if (!hookCommands || hookCommands.length === 0) { - return results; - } - - const transcriptPath = this._sessionTranscriptPaths.get(sessionKey); - - const requestId = this._requestCounter++; - const token = options?.token ?? CancellationToken.None; - - this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); - this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); - - for (const hookCommand of hookCommands) { - const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token); - results.push(result); - - // If stopReason is set, stop processing remaining hooks - if (result.stopReason) { - this._log(requestId, hookType, `Stopping: ${result.stopReason}`); - break; - } - } - - // Emit aggregated warnings for any hook results that had warning messages - this._emitAggregatedWarnings(hookType, sessionResource, results); - - // If any hook set stopReason, emit progress so it's visible to the user - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason) { - this._emitHookProgress(hookType, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - } - - return results; - } finally { - this._onDidExecuteHook.fire({ - hookType, - sessionResource, - input: options?.input, - results, - durationMs: Math.round(sw.elapsed()), - }); - } - } - -} - -/** - * Error thrown when a hook requests the agent to abort processing. - * The message should be shown to the user. - */ -export class HookAbortError extends Error { - constructor( - public readonly hookType: string, - public readonly stopReason: string - ) { - super(`Hook ${hookType} aborted: ${stopReason}`); - this.name = 'HookAbortError'; - } -} - -/** - * Formats a localized error message for a failed hook. - * @param errorMessage The error message from the hook - * @returns A localized error message string - */ -export function formatHookErrorMessage(errorMessage: string): string { - if (errorMessage) { - return localize('hookFatalErrorWithMessage', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details. Error message: {0}', errorMessage); - } - return localize('hookFatalError', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details.'); -} diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts deleted file mode 100644 index 1c4bceb5314..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Internal hook types - types used within VS Code's hooks execution service. - * - * "Internal" means these types are used by VS Code code only - they never cross the - * process boundary to external hook commands. They use camelCase for field names. - * - * External types (in hooksCommandTypes.ts) define the contract with spawned commands. - */ - -import { vBoolean, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; - -//#region Common Hook Types - -/** - * The kind of result from executing a hook command. - */ -export type HookResultKind = 'success' | 'error' | 'warning'; - -/** - * Semantic hook result with common fields extracted and defaults applied. - * This is what callers receive from executeHook. - */ -export interface IHookResult { - /** - * The kind of result from executing the hook. - */ - readonly resultKind: HookResultKind; - /** - * If set, the agent should stop processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Warning message shown to the user. - * (Mapped from `systemMessage` in command output, or stderr for non-blocking errors.) - */ - readonly warningMessage?: string; - /** - * The hook's output (hook-specific fields only). - * For errors, this is the error message string. - */ - readonly output: unknown; -} - -export const commonHookOutputValidator = vObj({ - continue: vOptionalProp(vBoolean()), - stopReason: vOptionalProp(vString()), - systemMessage: vOptionalProp(vString()), -}); - -//#endregion diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index ee806bff9f4..0243dcfdd0d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -47,7 +47,6 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { IHooksExecutionService } from '../../../common/hooks/hooksExecutionService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; @@ -90,9 +89,6 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); - collection.set(IHooksExecutionService, new class extends mock() { - override registerHooks() { return Disposable.None; } - }); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index a5b61a26879..3b53ed241ba 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -8,7 +8,6 @@ import { Barrier } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js'; -import { Event } from '../../../../../../base/common/event.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -34,10 +33,6 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { IHookResult } from '../../../common/hooks/hooksTypes.js'; -import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; // --- Test helpers to reduce repetition and improve readability --- @@ -65,19 +60,6 @@ class TestTelemetryService implements Partial { } } -class MockHooksExecutionService implements IHooksExecutionService { - readonly _serviceBrand: undefined; - readonly onDidExecuteHook = Event.None; - readonly onDidHookProgress = Event.None; - - setProxy(_proxy: IHooksExecutionProxy): void { } - registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } - getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; } - executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { - return Promise.resolve([]); - } -} - function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { const toolData: IToolData = { id, @@ -136,7 +118,6 @@ interface TestToolsServiceOptions { accessibilityService?: IAccessibilityService; accessibilitySignalService?: Partial; telemetryService?: Partial; - hooksExecutionService?: MockHooksExecutionService; commandService?: Partial; /** Called after configurationService is created but before the service is instantiated */ configureServices?: (config: TestConfigurationService) => void; @@ -161,7 +142,6 @@ function createTestToolsService(store: ReturnType { instaService1.stub(IAccessibilityService, testAccessibilityService1); instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService1.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService1.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService)); const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', { @@ -1774,7 +1753,6 @@ suite('LanguageModelToolsService', () => { instaService2.stub(IAccessibilityService, testAccessibilityService2); instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService2.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService2.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService)); const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', { @@ -1817,7 +1795,6 @@ suite('LanguageModelToolsService', () => { instaService3.stub(IAccessibilityService, testAccessibilityService3); instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService3.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService3.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService)); const tool3 = registerToolForTest(testService3, store, 'offTool', { @@ -2587,7 +2564,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); const tool = registerToolForTest(testService, store, 'gitCommitTool', { @@ -2626,7 +2602,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2666,7 +2641,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2709,7 +2683,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -3799,15 +3772,11 @@ suite('LanguageModelToolsService', () => { }); suite('preToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; let hookService: LanguageModelToolsService; let hookChatService: MockChatService; setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); + const setup = createTestToolsService(store); hookService = setup.service; hookChatService = setup.chatService; }); @@ -4045,9 +4014,7 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); @@ -4101,9 +4068,7 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts deleted file mode 100644 index 1493f35a29a..00000000000 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ /dev/null @@ -1,402 +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 { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js'; -import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js'; -import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; -import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js'; - -function cmd(command: string): IHookCommand { - return { type: 'command', command, cwd: URI.file('/') }; -} - -function createMockOutputService(): IOutputService { - const mockChannel: Partial = { - append: () => { }, - }; - return { - _serviceBrand: undefined, - getChannel: () => mockChannel as IOutputChannel, - } as unknown as IOutputService; -} - -suite('HooksExecutionService', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - let service: HooksExecutionService; - const sessionUri = URI.file('/test/session'); - - setup(() => { - service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService())); - }); - - suite('registerHooks', () => { - test('registers hooks for a session', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - }); - - test('returns disposable that unregisters hooks', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - const disposable = service.registerHooks(sessionUri, hooks); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - - disposable.dispose(); - - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - - test('different sessions have independent hooks', () => { - const session1 = URI.file('/test/session1'); - const session2 = URI.file('/test/session2'); - const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] }; - const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] }; - - store.add(service.registerHooks(session1, hooks1)); - store.add(service.registerHooks(session2, hooks2)); - - assert.strictEqual(service.getHooksForSession(session1), hooks1); - assert.strictEqual(service.getHooksForSession(session2), hooks2); - }); - }); - - suite('getHooksForSession', () => { - test('returns undefined for unregistered session', () => { - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - }); - - suite('executeHook', () => { - test('returns empty array when no proxy set', async () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks registered for session', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks of requested type', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PostToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('executes hook commands via proxy and returns semantic results', async () => { - const proxy = createMockProxy((cmd) => ({ - kind: HookCommandResultKind.Success, - result: `executed: ${cmd.command}` - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' }); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].output, 'executed: echo test'); - }); - - test('executes multiple hook commands in order', async () => { - const executedCommands: string[] = []; - const proxy = createMockProxy((cmd) => { - executedCommands.push(cmd.command ?? ''); - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { - [HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')] - }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 3); - assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); - }); - - test('wraps proxy errors in warning result', async () => { - const proxy = createMockProxy(() => { - throw new Error('proxy failed'); - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - // Proxy errors are now treated as warnings (non-blocking) - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].warningMessage, 'proxy failed'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes cancellation token to proxy', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const cts = store.add(new CancellationTokenSource()); - await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); - - assert.strictEqual(receivedToken, cts.token); - }); - - test('uses CancellationToken.None when no token provided', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(receivedToken, CancellationToken.None); - }); - - test('extracts common fields from successful result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - stopReason: 'User requested stop', - systemMessage: 'Warning: hook triggered', - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'User requested stop'); - assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered'); - // Hook-specific fields are in output with wrapper - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles continue false by setting stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - systemMessage: 'User requested to stop' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - // continue:false without explicit stopReason sets stopReason to empty string - assert.strictEqual(results[0].stopReason, ''); - assert.strictEqual(results[0].warningMessage, 'User requested to stop'); - }); - - test('stopReason takes precedence over continue false', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - stopReason: 'Explicit stop reason' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'Explicit stop reason'); - }); - - test('uses defaults when no common fields present', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].warningMessage, undefined); - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles error results from command (exit code 2) as stop with stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Error, - result: 'command failed with error' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - // Exit code 2 produces error with stopReason - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].stopReason, 'command failed with error'); - assert.strictEqual(results[0].output, undefined); - }); - - test('handles non-blocking error results from command', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: 'non-blocking warning message' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, 'non-blocking warning message'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('handles non-blocking error with object result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: { code: 'WARN_001', message: 'Something went wrong' } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes through hook-specific output fields for non-preToolUse hooks', async () => { - // Stop hooks return different fields (decision, reason) than preToolUse hooks - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Please run the tests' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.Stop]: [cmd('check-stop')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.Stop, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - // Hook-specific fields should be in output, not undefined - assert.deepStrictEqual(results[0].output, { - decision: 'block', - reason: 'Please run the tests' - }); - }); - - test('passes input to proxy', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const testInput = { foo: 'bar', nested: { value: 123 } }; - await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); - - // Input includes caller properties merged with common hook properties - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['foo'], 'bar'); - assert.deepStrictEqual(input['nested'], { value: 123 }); - // Common properties are also present - assert.strictEqual(typeof input['timestamp'], 'string'); - assert.strictEqual(input['hookEventName'], HookType.PreToolUse); - }); - }); - - function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy { - return { - runHookCommand: async (hookCommand, input, token) => { - if (handler) { - return handler(hookCommand, input, token); - } - return { kind: HookCommandResultKind.Success, result: 'mock result' }; - } - }; - } -}); diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index 39064b88952..1540fea982c 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 5 declare module 'vscode' { @@ -13,18 +13,33 @@ declare module 'vscode' { export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; /** - * Options for executing a hook command. + * A resolved hook command ready for execution. + * The command has already been resolved for the current platform. */ - export interface ChatHookExecutionOptions { + export interface ChatHookCommand { /** - * Input data to pass to the hook via stdin (will be JSON-serialized). + * The shell command to execute, already resolved for the current platform. */ - readonly input?: unknown; + readonly command: string; /** - * The tool invocation token from the chat request context, - * used to associate the hook execution with the current chat session. + * Working directory for the command. */ - readonly toolInvocationToken: ChatParticipantToolToken; + readonly cwd?: Uri; + /** + * Additional environment variables for the command. + */ + readonly env?: Record; + /** + * Maximum execution time in seconds. + */ + readonly timeoutSec?: number; + } + + /** + * Collected hooks for a chat request, organized by hook type. + */ + export interface ChatRequestHooks { + readonly [hookType: string]: readonly ChatHookCommand[]; } /** @@ -60,20 +75,15 @@ declare module 'vscode' { readonly output: unknown; } - export namespace chat { + export interface ChatRequest { /** - * Execute all hooks of the specified type for the current chat session. - * Hooks are configured in hooks .json files in the workspace. - * - * @param hookType The type of hook to execute. - * @param options Hook execution options including the input data. - * @param token Optional cancellation token. - * @returns A promise that resolves to an array of hook execution results. + * Resolved hook commands for this request, organized by hook type. + * The commands have already been resolved for the current platform. + * Only present when hooks are enabled. */ - export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable; + readonly hooks?: ChatRequestHooks; } - /** * A progress part representing the execution result of a hook. * Hooks are user-configured scripts that run at specific points during chat processing. From ea12208c7f9dc50b2114e78204b249d13102733e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 11 Feb 2026 00:08:52 +0100 Subject: [PATCH 63/64] set expiration time for data (#294240) --- .../accounts/browser/defaultAccount.ts | 116 ++++++++++-------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 868cfac8c79..bc2ada6a3d9 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../authentication/common/authentication.js'; -import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { asJson, IRequestService, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -189,11 +189,12 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount interface IAccountPolicyData { readonly accountId: string; readonly policyData: IPolicyData; - readonly isTokenEntitlementsDataFetched: boolean; - readonly isMcpRegistryDataFetched: boolean; + readonly tokenEntitlementsFetchedAt?: number; + readonly mcpRegistryDataFetchedAt?: number; } interface IDefaultAccountData { + accountId: string; defaultAccount: IDefaultAccount; policyData: IAccountPolicyData | null; } @@ -261,7 +262,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const { accountId, policyData } = JSON.parse(cached); if (accountId && policyData) { this.logService.debug('[DefaultAccount] Initializing with cached policy data'); - return { accountId, policyData, isTokenEntitlementsDataFetched: false, isMcpRegistryDataFetched: false }; + return { accountId, policyData }; } } catch (error) { this.logService.error('[DefaultAccount] Failed to parse cached policy data', getErrorMessage(error)); @@ -284,7 +285,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Starting initialization'); - await this.doUpdateDefaultAccount(false); + await this.doUpdateDefaultAccount(); this.logService.debug('[DefaultAccount] Initialization complete'); this._register(this.onDidChangeDefaultAccount(account => { @@ -332,11 +333,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid })); this._register(this.hostService.onDidChangeFocus(focused => { - // Refresh default account when window gets focused and policy data is not fully fetched, to ensure we have the latest policy data. - if (focused && this._policyData && (!this._policyData.isMcpRegistryDataFetched || !this._policyData.isTokenEntitlementsDataFetched)) { - this.accountDataPollScheduler.cancel(); - this.logService.debug('[DefaultAccount] Window focused, updating default account'); - this.refresh(); + if (focused) { + this.refetchDefaultAccount(true); } })); } @@ -348,27 +346,31 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Refreshing default account'); + await this.updateDefaultAccount(); return this.defaultAccount; } - private async refetchDefaultAccount(): Promise { - if (!this.hostService.hasFocus) { + private async refetchDefaultAccount(useExistingEntitlements?: boolean): Promise { + if (this.accountDataPollScheduler.isScheduled()) { + this.accountDataPollScheduler.cancel(); + } + if (!this.hostService.hasFocus || !this._defaultAccount) { this.scheduleAccountDataPoll(); - this.logService.debug('[DefaultAccount] Skipping refetching default account because window is not focused'); + this.logService.debug('[DefaultAccount] Skipping refetching default account. Host is not focused or default account is not set'); return; } this.logService.debug('[DefaultAccount] Refetching default account'); - await this.updateDefaultAccount(true); + await this.updateDefaultAccount(useExistingEntitlements); } - private async updateDefaultAccount(donotUseLastFetchedData: boolean = false): Promise { - await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(donotUseLastFetchedData)); + private async updateDefaultAccount(useExistingEntitlements?: boolean): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(useExistingEntitlements)); } - private async doUpdateDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async doUpdateDefaultAccount(useExistingEntitlements: boolean = false): Promise { try { - const defaultAccount = await this.fetchDefaultAccount(donotUseLastFetchedData); + const defaultAccount = await this.fetchDefaultAccount(useExistingEntitlements); this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -376,7 +378,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async fetchDefaultAccount(useExistingEntitlements: boolean): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -386,7 +388,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, donotUseLastFetchedData); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, useExistingEntitlements); } private setDefaultAccount(account: IDefaultAccountData | null): void { @@ -449,7 +451,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, useExistingEntitlements: boolean): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); @@ -459,39 +461,39 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, donotUseLastFetchedData); + return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, useExistingEntitlements); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], useExistingEntitlements: boolean): Promise { try { const accountId = sessions[0].account.id; + const existingEntitlementsData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; - const [entitlementsData, tokenEntitlementsData] = await Promise.all([ - this.getEntitlements(sessions), - this.getTokenEntitlements(sessions, donotUseLastFetchedData ? undefined : accountPolicyData), + const [entitlementsData, tokenEntitlementsResult] = await Promise.all([ + useExistingEntitlements && existingEntitlementsData ? existingEntitlementsData : this.getEntitlements(sessions), + this.getTokenEntitlements(sessions, accountPolicyData), ]); - let isTokenEntitlementsDataFetched = false; - let isMcpRegistryDataFetched = false; + let tokenEntitlementsFetchedAt: number | undefined; + let mcpRegistryDataFetchedAt: number | undefined; let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; - if (tokenEntitlementsData) { - isTokenEntitlementsDataFetched = true; + if (tokenEntitlementsResult) { + tokenEntitlementsFetchedAt = tokenEntitlementsResult.fetchedAt; + const tokenEntitlementsData = tokenEntitlementsResult.data; policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.chat_agent_enabled; policyData.chat_preview_features_enabled = tokenEntitlementsData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.mcp; if (policyData.mcp) { - const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, donotUseLastFetchedData ? undefined : accountPolicyData); - if (!isUndefined(mcpRegistryProvider)) { - isMcpRegistryDataFetched = true; - policyData.mcpRegistryUrl = mcpRegistryProvider?.url; - policyData.mcpAccess = mcpRegistryProvider?.registry_access; - } + const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData); + mcpRegistryDataFetchedAt = mcpRegistryResult?.fetchedAt; + policyData.mcpRegistryUrl = mcpRegistryResult?.data?.url; + policyData.mcpAccess = mcpRegistryResult?.data?.registry_access; } else { policyData.mcpRegistryUrl = undefined; policyData.mcpAccess = undefined; @@ -505,7 +507,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid entitlementsData, }; this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); - return { defaultAccount, policyData: policyData ? { accountId, policyData, isTokenEntitlementsDataFetched, isMcpRegistryDataFetched } : null }; + return { defaultAccount, accountId, policyData: policyData ? { accountId, policyData, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt } : null }; } catch (error) { this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; @@ -560,12 +562,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise | undefined> { - if (accountPolicyData?.isTokenEntitlementsDataFetched) { + private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: Partial; fetchedAt: number } | undefined> { + if (accountPolicyData?.tokenEntitlementsFetchedAt && !this.isDataStale(accountPolicyData.tokenEntitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched token entitlements data'); - return accountPolicyData.policyData; + return { data: accountPolicyData.policyData, fetchedAt: accountPolicyData.tokenEntitlementsFetchedAt }; } - return await this.requestTokenEntitlements(sessions); + const data = await this.requestTokenEntitlements(sessions); + return data ? { data, fetchedAt: Date.now() } : undefined; } private async requestTokenEntitlements(sessions: AuthenticationSession[]): Promise | undefined> { @@ -639,12 +642,14 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise { - if (accountPolicyData?.isMcpRegistryDataFetched) { + private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { + if (accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched MCP registry data'); - return accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; } - return await this.requestMcpRegistryProvider(sessions); + const data = await this.requestMcpRegistryProvider(sessions); + return !isUndefined(data) ? { data, fetchedAt: Date.now() } : undefined; } private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { @@ -660,11 +665,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - if (response.res.statusCode && response.res.statusCode !== 200) { - this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); - return response.res.statusCode === 404 /* mcp not configured */ - ? null - : undefined; + if (!isSuccess(response)) { + if (isClientError(response)) { + this.logService.debug(`[DefaultAccount] Received ${response.res.statusCode} for MCP registry data, treating as no registry available.`); + return null; + } + this.logService.debug(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); + return undefined; } try { @@ -703,7 +710,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }, token); const status = response.res.statusCode; - if (status && status !== 200) { + if (status === 401 || status === 404) { + this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session } @@ -711,7 +719,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return response; } catch (error) { if (!token.isCancellationRequested) { - this.logService.error(`[chat entitlement] request: error ${error}`); + this.logService.error(`[DefaultAccount] request: error ${error}`, url); } } } @@ -724,6 +732,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return lastResponse; } + private isDataStale(fetchedAt: number): boolean { + return (Date.now() - fetchedAt) >= ACCOUNT_DATA_POLL_INTERVAL_MS; + } + private getEntitlementUrl(): string | undefined { if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { From 5075f3e01463ec577ef82e5e1347e370e912c136 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 17:12:48 -0600 Subject: [PATCH 64/64] Tweak tips (#294248) * Tweak tips * add steering --- .../workbench/contrib/chat/browser/chatTipService.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 5d673cca996..5c55c8159ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -164,13 +164,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.messageQueueing', - message: localize('tip.messageQueueing', "Tip: You can send follow-up messages while the agent is working. They'll be queued and processed in order."), + message: localize('tip.messageQueueing', "Tip: You can send follow-up messages and steering while the agent is working. They'll be queued and processed in order."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], }, { id: 'tip.yoloMode', - 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."), + message: localize('tip.yoloMode', "Tip: Enable [auto approval](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), @@ -179,13 +179,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.mermaid', - message: localize('tip.mermaid', "Tip: Ask the agent to visualize architectures and flows; it can render Mermaid diagrams directly in chat."), + message: localize('tip.mermaid', "Tip: Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['renderMermaidDiagram'], }, { id: 'tip.githubRepo', - 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."), + message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt to let the agent search code, browse issues, and explore pull requests from that repo."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), @@ -194,13 +194,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.subagents', - message: localize('tip.subagents', "Tip: Ask the agent to implement a plan in parallel; it can delegate work across subagents for faster results."), + message: localize('tip.subagents', "Tip: For large tasks, ask the agent to work in parallel. It can split the work across subagents to finish faster."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['runSubagent'], }, { id: 'tip.contextUsage', - 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."), + message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being used and what's consuming them."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.contextUsageHasBeenOpened.negate(),