diff --git a/.eslint-plugin-local/code-no-declare-const-enum.ts b/.eslint-plugin-local/code-no-declare-const-enum.ts new file mode 100644 index 0000000000000..b448adee89c53 --- /dev/null +++ b/.eslint-plugin-local/code-no-declare-const-enum.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; + +/** + * Disallows `declare const enum` declarations. esbuild does not inline + * `declare const enum` values, leaving the enum identifier in the output + * which causes a ReferenceError at runtime. + * + * Use `const enum` (without `declare`) instead. + * + * See https://github.com/evanw/esbuild/issues/4394 + */ +export default new class NoDeclareConstEnum implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noDeclareConstEnum: '"declare const enum" is not supported by esbuild. Use "const enum" instead. See https://github.com/evanw/esbuild/issues/4394', + }, + schema: false, + fixable: 'code', + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + return { + TSEnumDeclaration(node: any) { + if (node.const && node.declare) { + context.report({ + node, + messageId: 'noDeclareConstEnum', + fix: (fixer) => { + // Remove "declare " from "declare const enum" + const sourceCode = context.sourceCode; + const text = sourceCode.getText(node); + const declareIndex = text.indexOf('declare'); + if (declareIndex !== -1) { + return fixer.removeRange([ + node.range[0] + declareIndex, + node.range[0] + declareIndex + 'declare '.length + ]); + } + return null; + } + }); + } + } + }; + } +}; diff --git a/build/win32/code.iss b/build/win32/code.iss index 0e2b143f3b8d9..f7091b28e5597 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1508,7 +1508,7 @@ var begin // Check if the user has forced Windows 10 style context menus on Windows 11 SubKey := 'Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32'; - Result := RegKeyExists(HKEY_CURRENT_USER, SubKey) or RegKeyExists(HKEY_LOCAL_MACHINE, SubKey); + Result := RegKeyExists(HKEY_CURRENT_USER, SubKey); end; function ShouldUseWindows11ContextMenu(): Boolean; @@ -1675,6 +1675,12 @@ begin if CurStep = ssPostInstall then begin #ifdef AppxPackageName + // Remove the appx package when user has forced Windows 10 context menus via + // registry. This handles the case where the user previously had the appx + // installed but now wants the classic context menu style. + if IsWindows10ContextMenuForced() then begin + RemoveAppxPackage(); + end; // Remove the old context menu registry keys if ShouldUseWindows11ContextMenu() then begin RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); diff --git a/eslint.config.js b/eslint.config.js index dfb489e0f0bc6..a0bcfc0556037 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -78,6 +78,7 @@ export default tseslint.config( 'semi': 'warn', 'local/code-translation-remind': 'warn', 'local/code-no-native-private': 'warn', + 'local/code-no-declare-const-enum': 'warn', 'local/code-parameter-properties-must-have-explicit-accessibility': 'warn', 'local/code-no-nls-in-standalone-editor': 'warn', 'local/code-no-potentially-unsafe-disposables': 'warn', diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 35fe38ae75a50..31bcd567e3129 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -199,6 +199,14 @@ outline: none !important; } +.monaco-workbench .quick-input-widget .quick-input-list .monaco-list-rows { + background: transparent !important; +} + +.monaco-workbench.vs .quick-input-widget .quick-input-list .monaco-list-row:hover:not(.selected):not(.focused) { + background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; +} + .monaco-workbench .monaco-editor .suggest-widget .monaco-list { border-radius: var(--radius-lg); } @@ -682,3 +690,167 @@ opacity: 1; color: var(--vscode-descriptionForeground); } + +/* ============================================================================================ + * Reduced Transparency - disable backdrop-filter blur and color-mix transparency effects + * for improved rendering performance. Controlled by workbench.reduceTransparency setting. + * ============================================================================================ */ + +/* Reset blur variables to none */ +.monaco-workbench.monaco-reduce-transparency { + --backdrop-blur-sm: none; + --backdrop-blur-md: none; + --backdrop-blur-lg: none; +} + +/* Quick Input (Command Palette) */ +.monaco-workbench.monaco-reduce-transparency .quick-input-widget { + background-color: var(--vscode-quickInput-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Notifications */ +.monaco-workbench.monaco-reduce-transparency .notification-toast-container { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-notifications-background) !important; +} + +.monaco-workbench.monaco-reduce-transparency .notifications-center { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-notifications-background) !important; +} + +/* Context Menu / Action Widget */ +.monaco-workbench.monaco-reduce-transparency .action-widget { + background: var(--vscode-menu-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Suggest Widget */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .suggest-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editorSuggestWidget-background) !important; +} + +/* Find Widget */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .find-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-workbench.monaco-reduce-transparency .inline-chat-gutter-menu { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Dialog */ +.monaco-workbench.monaco-reduce-transparency .monaco-dialog-box { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editor-background) !important; +} + +/* Peek View */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .peekview-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-peekViewEditor-background) !important; +} + +/* Hover */ +.monaco-reduce-transparency .monaco-hover { + background-color: var(--vscode-editorHoverWidget-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-reduce-transparency .monaco-hover.workbench-hover, +.monaco-reduce-transparency .workbench-hover { + background-color: var(--vscode-editorHoverWidget-background) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Keybinding Widget */ +.monaco-workbench.monaco-reduce-transparency .defineKeybindingWidget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Chat Editor Overlay */ +.monaco-workbench.monaco-reduce-transparency .chat-editor-overlay-widget, +.monaco-workbench.monaco-reduce-transparency .chat-diff-change-content-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Debug Toolbar */ +.monaco-workbench.monaco-reduce-transparency .debug-toolbar { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +.monaco-workbench.monaco-reduce-transparency .debug-hover-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Parameter Hints */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .parameter-hints-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editorWidget-background) !important; +} + +/* Sticky Scroll */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--vscode-editor-background) !important; +} + +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-lines { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--vscode-editor-background) !important; +} + +/* Rename Box */ +.monaco-reduce-transparency .monaco-editor .rename-box.preview { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Notebook */ +.monaco-workbench.monaco-reduce-transparency .notebookOverlay .monaco-list-row .cell-title-toolbar { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Command Center */ +.monaco-workbench.monaco-reduce-transparency .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { + background: var(--vscode-commandCenter-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-workbench.monaco-reduce-transparency .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { + background: var(--vscode-commandCenter-activeBackground) !important; +} + +/* Breadcrumbs */ +.monaco-workbench.monaco-reduce-transparency .breadcrumbs-picker-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-breadcrumbPicker-background) !important; +} + +/* Quick Input filter input */ +.monaco-workbench.monaco-reduce-transparency .quick-input-widget .quick-input-filter .monaco-inputbox { + background: var(--vscode-input-background) !important; +} diff --git a/package-lock.json b/package-lock.json index 6bc08ff095670..66ff655b1b9e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -2947,9 +2947,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-6", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", - "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", + "version": "0.0.45-7", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-7.tgz", + "integrity": "sha512-z7B3hI6LfrFY8uo0PrAHkZ0K0XQbKTshIHlDe5qyf0j6sjM2vNlzdj2FA8HgasYKBQ3zzpZzD/GK8on2A1AKRA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/deviceid": { diff --git a/package.json b/package.json index d5743f86c506d..5de9732104172 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 48c97fba4c5fd..663f8c5c185d2 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-6", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", - "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", + "version": "0.0.45-7", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-7.tgz", + "integrity": "sha512-z7B3hI6LfrFY8uo0PrAHkZ0K0XQbKTshIHlDe5qyf0j6sjM2vNlzdj2FA8HgasYKBQ3zzpZzD/GK8on2A1AKRA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 4f591ed99068f..97b681d67b2af 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 6c972e7866b02..fceb9c852cce1 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -58,6 +58,7 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + readonly onVisibilityChange?: (window: Window, visible: boolean) => void; readonly buttonStyles: IButtonStyles; readonly checkboxStyles: ICheckboxStyles; readonly inputBoxStyles: IInputBoxStyles; @@ -536,6 +537,10 @@ export class Dialog extends Disposable { this.element.setAttribute('aria-describedby', 'monaco-dialog-icon monaco-dialog-message-text monaco-dialog-message-detail monaco-dialog-message-body monaco-dialog-footer'); show(this.element); + // Notify visibility change + this.options.onVisibilityChange?.(window, true); + this._register(toDisposable(() => this.options.onVisibilityChange?.(window, false))); + // Focus first element (input or button) if (this.inputs.length > 0) { this.inputs[0].focus(); diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 0ff9490a05801..f541d4face8e6 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -654,4 +654,5 @@ export const codiconsLibrary = { ask: register('ask', 0xec80), openai: register('openai', 0xec81), claude: register('claude', 0xec82), + openInWindow: register('open-in-window', 0xec83), } as const; diff --git a/src/vs/editor/common/languageFeatureRegistry.ts b/src/vs/editor/common/languageFeatureRegistry.ts index d76b0906419f4..c0a0c07d2f6a9 100644 --- a/src/vs/editor/common/languageFeatureRegistry.ts +++ b/src/vs/editor/common/languageFeatureRegistry.ts @@ -6,7 +6,7 @@ import { Emitter } from '../../base/common/event.js'; import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { ITextModel, shouldSynchronizeModel } from './model.js'; -import { LanguageFilter, LanguageSelector, score } from './languageSelector.js'; +import { LanguageFilter, LanguageSelector, score, selectLanguageIds } from './languageSelector.js'; import { URI } from '../../base/common/uri.js'; interface Entry { @@ -115,6 +115,14 @@ export class LanguageFeatureRegistry { return this._entries.map(entry => entry.provider); } + get registeredLanguageIds(): ReadonlySet { + const result = new Set(); + for (const entry of this._entries) { + selectLanguageIds(entry.selector, result); + } + return result; + } + ordered(model: ITextModel, recursive = false): T[] { const result: T[] = []; this._orderedForEach(model, recursive, entry => result.push(entry.provider)); @@ -226,4 +234,3 @@ function isBuiltinSelector(selector: LanguageSelector): boolean { return Boolean((selector as LanguageFilter).isBuiltin); } - diff --git a/src/vs/editor/common/languageSelector.ts b/src/vs/editor/common/languageSelector.ts index 6374d380f48e5..80ffb5450d19d 100644 --- a/src/vs/editor/common/languageSelector.ts +++ b/src/vs/editor/common/languageSelector.ts @@ -142,3 +142,18 @@ export function targetsNotebooks(selector: LanguageSelector): boolean { return !!(selector).notebookType; } } + +export function selectLanguageIds(selector: LanguageSelector, into: Set): void { + if (typeof selector === 'string') { + into.add(selector); + } else if (Array.isArray(selector)) { + for (const item of selector) { + selectLanguageIds(item, into); + } + } else { + const language = (selector).language; + if (language) { + into.add(language); + } + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 6892e304e968d..f4b3115a52ad7 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -279,6 +279,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, refresh: async () => { return null; }, signIn: async () => { return null; }, + signOut: async () => { }, }); options.serviceCollection.set(IRenameSymbolTrackerService, new NullRenameSymbolTrackerService()); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 90c8c11a1f8c3..0824dfbb53337 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1139,6 +1139,10 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { async signIn(): Promise { return null; } + + async signOut(): Promise { + // no-op + } } export interface IEditorOverrideServices { diff --git a/src/vs/editor/test/common/modes/languageSelector.test.ts b/src/vs/editor/test/common/modes/languageSelector.test.ts index 31f6c051af466..be9c597f98f9d 100644 --- a/src/vs/editor/test/common/modes/languageSelector.test.ts +++ b/src/vs/editor/test/common/modes/languageSelector.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { LanguageSelector, score } from '../../../common/languageSelector.js'; +import { LanguageSelector, score, selectLanguageIds } from '../../../common/languageSelector.js'; suite('LanguageSelector', function () { @@ -173,4 +173,27 @@ suite('LanguageSelector', function () { }, obj.uri, obj.langId, true, undefined, undefined); assert.strictEqual(value, 0); }); + + test('selectLanguageIds', function () { + const result = new Set(); + + selectLanguageIds('typescript', result); + assert.deepStrictEqual([...result], ['typescript']); + + result.clear(); + selectLanguageIds({ language: 'python', scheme: 'file' }, result); + assert.deepStrictEqual([...result], ['python']); + + result.clear(); + selectLanguageIds({ scheme: 'file' }, result); + assert.deepStrictEqual([...result], []); + + result.clear(); + selectLanguageIds(['javascript', { language: 'css' }, { scheme: 'untitled' }], result); + assert.deepStrictEqual([...result].sort(), ['css', 'javascript']); + + result.clear(); + selectLanguageIds('*', result); + assert.deepStrictEqual([...result], ['*']); + }); }); diff --git a/src/vs/platform/accessibility/browser/accessibilityService.ts b/src/vs/platform/accessibility/browser/accessibilityService.ts index d6db2a65b33da..35f6639e2697f 100644 --- a/src/vs/platform/accessibility/browser/accessibilityService.ts +++ b/src/vs/platform/accessibility/browser/accessibilityService.ts @@ -24,6 +24,10 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe protected _systemMotionReduced: boolean; protected readonly _onDidChangeReducedMotion = this._register(new Emitter()); + protected _configTransparencyReduced: 'auto' | 'on' | 'off'; + protected _systemTransparencyReduced: boolean; + protected readonly _onDidChangeReducedTransparency = this._register(new Emitter()); + private _linkUnderlinesEnabled: boolean; protected readonly _onDidChangeLinkUnderline = this._register(new Emitter()); @@ -45,6 +49,10 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._configMotionReduced = this._configurationService.getValue('workbench.reduceMotion'); this._onDidChangeReducedMotion.fire(); } + if (e.affectsConfiguration('workbench.reduceTransparency')) { + this._configTransparencyReduced = this._configurationService.getValue('workbench.reduceTransparency'); + this._onDidChangeReducedTransparency.fire(); + } })); updateContextKey(); this._register(this.onDidChangeScreenReaderOptimized(() => updateContextKey())); @@ -53,9 +61,14 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._systemMotionReduced = reduceMotionMatcher.matches; this._configMotionReduced = this._configurationService.getValue<'auto' | 'on' | 'off'>('workbench.reduceMotion'); + const reduceTransparencyMatcher = mainWindow.matchMedia(`(prefers-reduced-transparency: reduce)`); + this._systemTransparencyReduced = reduceTransparencyMatcher.matches; + this._configTransparencyReduced = this._configurationService.getValue<'auto' | 'on' | 'off'>('workbench.reduceTransparency'); + this._linkUnderlinesEnabled = this._configurationService.getValue('accessibility.underlineLinks'); this.initReducedMotionListeners(reduceMotionMatcher); + this.initReducedTransparencyListeners(reduceTransparencyMatcher); this.initLinkUnderlineListeners(); } @@ -78,6 +91,24 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._register(this.onDidChangeReducedMotion(() => updateRootClasses())); } + private initReducedTransparencyListeners(reduceTransparencyMatcher: MediaQueryList) { + + this._register(addDisposableListener(reduceTransparencyMatcher, 'change', () => { + this._systemTransparencyReduced = reduceTransparencyMatcher.matches; + if (this._configTransparencyReduced === 'auto') { + this._onDidChangeReducedTransparency.fire(); + } + })); + + const updateRootClasses = () => { + const reduce = this.isTransparencyReduced(); + this._layoutService.mainContainer.classList.toggle('monaco-reduce-transparency', reduce); + }; + + updateRootClasses(); + this._register(this.onDidChangeReducedTransparency(() => updateRootClasses())); + } + private initLinkUnderlineListeners() { this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('accessibility.underlineLinks')) { @@ -119,6 +150,15 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe return config === 'on' || (config === 'auto' && this._systemMotionReduced); } + get onDidChangeReducedTransparency(): Event { + return this._onDidChangeReducedTransparency.event; + } + + isTransparencyReduced(): boolean { + const config = this._configTransparencyReduced; + return config === 'on' || (config === 'auto' && this._systemTransparencyReduced); + } + alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } diff --git a/src/vs/platform/accessibility/common/accessibility.ts b/src/vs/platform/accessibility/common/accessibility.ts index 1757eb84e024b..741d5fffc5b34 100644 --- a/src/vs/platform/accessibility/common/accessibility.ts +++ b/src/vs/platform/accessibility/common/accessibility.ts @@ -14,10 +14,12 @@ export interface IAccessibilityService { readonly onDidChangeScreenReaderOptimized: Event; readonly onDidChangeReducedMotion: Event; + readonly onDidChangeReducedTransparency: Event; alwaysUnderlineAccessKeys(): Promise; isScreenReaderOptimized(): boolean; isMotionReduced(): boolean; + isTransparencyReduced(): boolean; getAccessibilitySupport(): AccessibilitySupport; setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void; alert(message: string): void; diff --git a/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts b/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts new file mode 100644 index 0000000000000..869869d10508f --- /dev/null +++ b/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { IConfigurationService, IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; +import { ILayoutService } from '../../../layout/browser/layoutService.js'; +import { AccessibilityService } from '../../browser/accessibilityService.js'; + +suite('AccessibilityService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let configurationService: TestConfigurationService; + let container: HTMLElement; + + function createService(config: Record = {}): AccessibilityService { + const instantiationService = store.add(new TestInstantiationService()); + + configurationService = new TestConfigurationService({ + 'editor.accessibilitySupport': 'off', + 'workbench.reduceMotion': 'off', + 'workbench.reduceTransparency': 'off', + 'accessibility.underlineLinks': false, + ...config, + }); + instantiationService.stub(IConfigurationService, configurationService); + + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + container = document.createElement('div'); + instantiationService.stub(ILayoutService, { + mainContainer: container, + activeContainer: container, + getContainer() { return container; }, + onDidLayoutContainer: Event.None, + }); + + return store.add(instantiationService.createInstance(AccessibilityService)); + } + + suite('isTransparencyReduced', () => { + + test('returns false when config is off', () => { + const service = createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(service.isTransparencyReduced(), false); + }); + + test('returns true when config is on', () => { + const service = createService({ 'workbench.reduceTransparency': 'on' }); + assert.strictEqual(service.isTransparencyReduced(), true); + }); + + test('adds CSS class when config is on', () => { + createService({ 'workbench.reduceTransparency': 'on' }); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), true); + }); + + test('does not add CSS class when config is off', () => { + createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), false); + }); + + test('fires event and updates class on config change', () => { + const service = createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(service.isTransparencyReduced(), false); + + let fired = false; + store.add(service.onDidChangeReducedTransparency(() => { fired = true; })); + + // Simulate config change + configurationService.setUserConfiguration('workbench.reduceTransparency', 'on'); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration(id: string) { return id === 'workbench.reduceTransparency'; }, + } satisfies Partial as unknown as IConfigurationChangeEvent); + + assert.strictEqual(fired, true); + assert.strictEqual(service.isTransparencyReduced(), true); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), true); + }); + }); + + suite('isMotionReduced', () => { + + test('returns false when config is off', () => { + const service = createService({ 'workbench.reduceMotion': 'off' }); + assert.strictEqual(service.isMotionReduced(), false); + }); + + test('returns true when config is on', () => { + const service = createService({ 'workbench.reduceMotion': 'on' }); + assert.strictEqual(service.isMotionReduced(), true); + }); + + test('adds CSS classes when config is on', () => { + createService({ 'workbench.reduceMotion': 'on' }); + assert.strictEqual(container.classList.contains('monaco-reduce-motion'), true); + assert.strictEqual(container.classList.contains('monaco-enable-motion'), false); + }); + + test('adds CSS classes when config is off', () => { + createService({ 'workbench.reduceMotion': 'off' }); + assert.strictEqual(container.classList.contains('monaco-reduce-motion'), false); + assert.strictEqual(container.classList.contains('monaco-enable-motion'), true); + }); + }); +}); diff --git a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts index 4f21111492ebe..6ef551ba9f21b 100644 --- a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts +++ b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts @@ -12,9 +12,11 @@ export class TestAccessibilityService implements IAccessibilityService { onDidChangeScreenReaderOptimized = Event.None; onDidChangeReducedMotion = Event.None; + onDidChangeReducedTransparency = Event.None; isScreenReaderOptimized(): boolean { return false; } isMotionReduced(): boolean { return true; } + isTransparencyReduced(): boolean { return false; } alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { } getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index a81fe449d6253..e9356c85f9df0 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -274,6 +274,7 @@ export class MenuId { static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu'); static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext'); static readonly ChatTipContext = new MenuId('ChatTipContext'); + static readonly ChatTipToolbar = new MenuId('ChatTipToolbar'); static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar'); static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu'); static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 63a9b956608ac..cd67c68841230 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -15,6 +15,7 @@ export interface IDefaultAccountProvider { getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; + signOut(): Promise; } export const IDefaultAccountService = createDecorator('defaultAccountService'); @@ -29,4 +30,5 @@ export interface IDefaultAccountService { setDefaultAccountProvider(provider: IDefaultAccountProvider): void; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; + signOut(): Promise; } diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 585585003d353..7369736706ca5 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -145,12 +145,7 @@ export interface ICommonNativeHostService { toggleWindowAlwaysOnTop(options?: INativeHostOptions): Promise; setWindowAlwaysOnTop(alwaysOnTop: boolean, options?: INativeHostOptions): Promise; - /** - * Only supported on Windows and macOS. Updates the window controls to match the title bar size. - * - * @param options `backgroundColor` and `foregroundColor` are only supported on Windows - */ - updateWindowControls(options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise; + updateWindowControls(options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise; updateWindowAccentColor(color: 'default' | 'off' | string, inactiveColor: string | undefined): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 22ad671ed56d0..93d000872d635 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -374,7 +374,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } } - async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise { + async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise { const window = this.windowById(options?.targetWindowId, windowId); window?.updateWindowControls(options); } diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index 13268e716eff3..748e90019de9c 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -38,7 +38,7 @@ export interface IBaseWindow extends IDisposable { readonly isFullScreen: boolean; toggleFullScreen(): void; - updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): void; + updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): void; matches(webContents: electron.WebContents): boolean; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 3841cfa34a759..4faede85f4cb8 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -46,6 +46,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { VSBuffer } from '../../../base/common/buffer.js'; import { errorHandler } from '../../../base/common/errors.js'; import { FocusMode } from '../../native/common/native.js'; +import { Color } from '../../../base/common/color.js'; export interface IWindowCreationOptions { readonly state: IWindowState; @@ -404,7 +405,10 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { private static readonly windowControlHeightStateStorageKey = 'windowControlHeight'; - updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): void { + private windowControlsDimmed = false; + private lastWindowControlColors: { backgroundColor?: string; foregroundColor?: string } | undefined; + + updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): void { const win = this.win; if (!win) { return; @@ -417,9 +421,25 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { // Windows/Linux: update window controls via setTitleBarOverlay() if (!isMacintosh && useWindowControlsOverlay(this.configurationService)) { + + // Update dimmed state if explicitly provided + if (options.dimmed !== undefined) { + this.windowControlsDimmed = options.dimmed; + } + + const backgroundColor = options.backgroundColor ?? this.lastWindowControlColors?.backgroundColor; + const foregroundColor = options.foregroundColor ?? this.lastWindowControlColors?.foregroundColor; + + if (options.backgroundColor !== undefined || options.foregroundColor !== undefined) { + this.lastWindowControlColors = { backgroundColor, foregroundColor }; + } + + const effectiveBackgroundColor = this.windowControlsDimmed && backgroundColor ? this.dimColor(backgroundColor) : backgroundColor; + const effectiveForegroundColor = this.windowControlsDimmed && foregroundColor ? this.dimColor(foregroundColor) : foregroundColor; + win.setTitleBarOverlay({ - color: options.backgroundColor?.trim() === '' ? undefined : options.backgroundColor, - symbolColor: options.foregroundColor?.trim() === '' ? undefined : options.foregroundColor, + color: effectiveBackgroundColor?.trim() === '' ? undefined : effectiveBackgroundColor, + symbolColor: effectiveForegroundColor?.trim() === '' ? undefined : effectiveForegroundColor, height: options.height ? options.height - 1 : undefined // account for window border }); } @@ -439,6 +459,24 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { } } + private dimColor(color: string): string { + + // Blend a CSS color with black at 30% opacity to match the + // dimming overlay of `rgba(0, 0, 0, 0.3)` used by modals. + + const parsed = Color.Format.CSS.parse(color); + if (!parsed) { + return color; + } + + const dimFactor = 0.7; // 1 - 0.3 opacity of black overlay + const r = Math.round(parsed.rgba.r * dimFactor); + const g = Math.round(parsed.rgba.g * dimFactor); + const b = Math.round(parsed.rgba.b * dimFactor); + + return `rgb(${r}, ${g}, ${b})`; + } + //#endregion //#region Fullscreen diff --git a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts index 2a95195c06bd9..eb3bad9ad55ae 100644 --- a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts +++ b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts @@ -73,7 +73,7 @@ suite('WindowsFinder', () => { isDocumentEdited(): boolean { throw new Error('Method not implemented.'); } updateTouchBar(items: UriDto[][]): void { throw new Error('Method not implemented.'); } serializeWindowState(): IWindowState { throw new Error('Method not implemented'); } - updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined }): void { throw new Error('Method not implemented.'); } + updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined; dimmed?: boolean | undefined }): void { throw new Error('Method not implemented.'); } notifyZoomLevel(level: number): void { throw new Error('Method not implemented.'); } matches(webContents: Electron.WebContents): boolean { throw new Error('Method not implemented.'); } dispose(): void { } diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index 5abfb88dd9cbc..4eefda8085bb1 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -20,7 +20,6 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { SwitchCompositeViewAction } from '../compositeBarActions.js'; const maximizeIcon = registerIcon('auxiliarybar-maximize', Codicon.screenFull, localize('maximizeIcon', 'Icon to maximize the secondary side bar.')); -const restoreIcon = registerIcon('auxiliarybar-restore', Codicon.screenNormal, localize('restoreIcon', 'Icon to restore the secondary side bar.')); const closeIcon = registerIcon('auxiliarybar-close', Codicon.close, localize('closeIcon', 'Icon to close the secondary side bar.')); const auxiliaryBarRightIcon = registerIcon('auxiliarybar-right-layout-icon', Codicon.layoutSidebarRight, localize('toggleAuxiliaryIconRight', 'Icon to toggle the secondary side bar off in its right position.')); @@ -224,17 +223,10 @@ class MaximizeAuxiliaryBar extends Action2 { super({ id: MaximizeAuxiliaryBar.ID, title: localize2('maximizeAuxiliaryBar', 'Maximize Secondary Side Bar'), - tooltip: localize('maximizeAuxiliaryBarTooltip', "Maximize Secondary Side Bar Size"), + tooltip: localize('maximizeAuxiliaryBarTooltip', "Maximize Secondary Side Bar"), category: Categories.View, f1: true, precondition: AuxiliaryBarMaximizedContext.negate(), - icon: maximizeIcon, - menu: { - id: MenuId.AuxiliaryBarTitle, - group: 'navigation', - order: 1, - when: AuxiliaryBarMaximizedContext.negate() - } }); } @@ -254,16 +246,14 @@ class RestoreAuxiliaryBar extends Action2 { super({ id: RestoreAuxiliaryBar.ID, title: localize2('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), - tooltip: localize('restoreAuxiliaryBarTooltip', "Restore Secondary Side Bar Size"), + tooltip: localize('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), category: Categories.View, f1: true, precondition: AuxiliaryBarMaximizedContext, - icon: restoreIcon, - menu: { - id: MenuId.AuxiliaryBarTitle, - group: 'navigation', - order: 1, - when: AuxiliaryBarMaximizedContext + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, } }); } @@ -284,8 +274,19 @@ class ToggleMaximizedAuxiliaryBar extends Action2 { super({ id: ToggleMaximizedAuxiliaryBar.ID, title: localize2('toggleMaximizedAuxiliaryBar', 'Toggle Maximized Secondary Side Bar'), + tooltip: localize('maximizeAuxiliaryBarTooltip2', "Maximize Secondary Side Bar"), f1: true, - category: Categories.View + category: Categories.View, + icon: maximizeIcon, + toggled: { + condition: AuxiliaryBarMaximizedContext, + tooltip: localize('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), + }, + menu: { + id: MenuId.AuxiliaryBarTitle, + group: 'navigation', + order: 1, + } }); } diff --git a/src/vs/platform/dialogs/browser/dialog.ts b/src/vs/workbench/browser/parts/dialogs/dialog.ts similarity index 65% rename from src/vs/platform/dialogs/browser/dialog.ts rename to src/vs/workbench/browser/parts/dialogs/dialog.ts index 01d12f126da19..d2f18acdb50b0 100644 --- a/src/vs/platform/dialogs/browser/dialog.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialog.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EventHelper } from '../../../base/browser/dom.js'; -import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; -import { IDialogOptions } from '../../../base/browser/ui/dialog/dialog.js'; -import { fromNow } from '../../../base/common/date.js'; -import { localize } from '../../../nls.js'; -import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { ResultKind } from '../../keybinding/common/keybindingResolver.js'; -import { ILayoutService } from '../../layout/browser/layoutService.js'; -import { IProductService } from '../../product/common/productService.js'; -import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../theme/browser/defaultStyles.js'; +import { EventHelper } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { IDialogOptions } from '../../../../base/browser/ui/dialog/dialog.js'; +import { fromNow } from '../../../../base/common/date.js'; +import { localize } from '../../../../nls.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; const defaultDialogAllowableCommands = new Set([ 'workbench.action.quit', @@ -25,7 +26,7 @@ const defaultDialogAllowableCommands = new Set([ 'editor.action.clipboardPasteAction' ]); -export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { +export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, hostService: IHostService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { return { keyEventProcessor: (event: StandardKeyboardEvent) => { const resolved = keybindingService.softDispatch(event, layoutService.activeContainer); @@ -39,6 +40,7 @@ export function createWorkbenchDialogOptions(options: Partial, k checkboxStyles: defaultCheckboxStyles, inputBoxStyles: defaultInputBoxStyles, dialogStyles: defaultDialogStyles, + onVisibilityChange: (window, visible) => hostService.setWindowDimmed(window, visible), ...options }; } diff --git a/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts b/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts index bba15b35fbba8..7e0e5ce8e9d2f 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts @@ -3,11 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IDialogsModel, IDialogViewItem } from '../../../common/dialogs.js'; @@ -16,9 +12,7 @@ import { DialogService } from '../../../services/dialogs/common/dialogService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { createBrowserAboutDialogDetails } from '../../../../platform/dialogs/browser/dialog.js'; -import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { createBrowserAboutDialogDetails } from './dialog.js'; export class DialogHandlerContribution extends Disposable implements IWorkbenchContribution { @@ -31,18 +25,12 @@ export class DialogHandlerContribution extends Disposable implements IWorkbenchC constructor( @IDialogService private dialogService: IDialogService, - @ILogService logService: ILogService, - @ILayoutService layoutService: ILayoutService, - @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private productService: IProductService, - @IClipboardService clipboardService: IClipboardService, - @IOpenerService openerService: IOpenerService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, ) { super(); - this.impl = new Lazy(() => new BrowserDialogHandler(logService, layoutService, keybindingService, instantiationService, clipboardService, openerService, markdownRendererService)); + this.impl = new Lazy(() => instantiationService.createInstance(BrowserDialogHandler)); this.model = (this.dialogService as DialogService).model; this._register(this.model.onWillShowDialog(() => { diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index 7664074eced18..16e11df36305b 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -15,7 +15,8 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from './dialog.js'; +import { IHostService } from '../../../services/host/browser/host.js'; export class BrowserDialogHandler extends AbstractDialogHandler { @@ -36,6 +37,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { @IClipboardService private readonly clipboardService: IClipboardService, @IOpenerService private readonly openerService: IOpenerService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IHostService private readonly hostService: IHostService, ) { super(); } @@ -119,7 +121,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { checkboxLabel: checkbox?.label, checkboxChecked: checkbox?.checked, inputs - }, this.keybindingService, this.layoutService, BrowserDialogHandler.ALLOWABLE_COMMANDS) + }, this.keybindingService, this.layoutService, this.hostService, BrowserDialogHandler.ALLOWABLE_COMMANDS) ); dialogDisposables.add(dialog); diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 693918dc2cacf..70508b4a9b380 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -1447,7 +1447,6 @@ function registerModalEditorCommands(): void { icon: Codicon.screenFull, toggled: { condition: EditorPartModalMaximizedContext, - icon: Codicon.screenNormal, title: localize('restoreModalEditorSize', "Restore Modal Editor") }, menu: { diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 9964e55f8407a..8571437e5dc58 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -57,6 +57,7 @@ export class ModalEditorPart { @IEditorService private readonly editorService: IEditorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, + @IHostService private readonly hostService: IHostService, ) { } @@ -110,6 +111,7 @@ export class ModalEditorPart { // Create toolbar disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { hiddenItemStrategy: HiddenItemStrategy.NoHide, + highlightToggledItems: true, menuOptions: { shouldForwardArgs: true } })); @@ -203,6 +205,10 @@ export class ModalEditorPart { disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); + // Dim window controls to match the modal overlay + this.hostService.setWindowDimmed(mainWindow, true); + disposables.add(toDisposable(() => this.hostService.setWindowDimmed(mainWindow, false))); + // Focus the modal editorPartContainer.focus(); diff --git a/src/vs/workbench/browser/parts/panel/panelActions.ts b/src/vs/workbench/browser/parts/panel/panelActions.ts index 3432144229c2f..10fcbca0b96e5 100644 --- a/src/vs/workbench/browser/parts/panel/panelActions.ts +++ b/src/vs/workbench/browser/parts/panel/panelActions.ts @@ -23,7 +23,6 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { SwitchCompositeViewAction } from '../compositeBarActions.js'; const maximizeIcon = registerIcon('panel-maximize', Codicon.screenFull, localize('maximizeIcon', 'Icon to maximize a panel.')); -const restoreIcon = registerIcon('panel-restore', Codicon.screenNormal, localize('restoreIcon', 'Icon to restore a panel.')); const closeIcon = registerIcon('panel-close', Codicon.close, localize('closeIcon', 'Icon to close a panel.')); const panelIcon = registerIcon('panel-layout-icon', Codicon.layoutPanel, localize('togglePanelOffIcon', 'Icon to toggle the panel off when it is on.')); const panelOffIcon = registerIcon('panel-layout-icon-off', Codicon.layoutPanelOff, localize('togglePanelOnIcon', 'Icon to toggle the panel on when it is off.')); @@ -274,18 +273,27 @@ registerAction2(class extends SwitchCompositeViewAction { }); const panelMaximizationSupportedWhen = ContextKeyExpr.or(PanelAlignmentContext.isEqualTo('center'), ContextKeyExpr.and(PanelPositionContext.notEqualsTo('bottom'), PanelPositionContext.notEqualsTo('top'))); -const ToggleMaximizedPanelActionId = 'workbench.action.toggleMaximizedPanel'; registerAction2(class extends Action2 { constructor() { super({ - id: ToggleMaximizedPanelActionId, + id: 'workbench.action.toggleMaximizedPanel', title: localize2('toggleMaximizedPanel', 'Toggle Maximized Panel'), - tooltip: localize('maximizePanel', "Maximize Panel Size"), + tooltip: localize('maximizePanel', "Maximize Panel"), category: Categories.View, f1: true, icon: maximizeIcon, - precondition: panelMaximizationSupportedWhen, // the workbench grid currently prevents us from supporting panel maximization with non-center panel alignment + precondition: panelMaximizationSupportedWhen, + toggled: { + condition: PanelMaximizedContext, + tooltip: localize('minimizePanel', "Restore Panel") + }, + menu: [{ + id: MenuId.PanelTitle, + group: 'navigation', + order: 1, + when: panelMaximizationSupportedWhen + }] }); } run(accessor: ServicesAccessor) { @@ -309,28 +317,6 @@ registerAction2(class extends Action2 { } }); -MenuRegistry.appendMenuItem(MenuId.PanelTitle, { - command: { - id: ToggleMaximizedPanelActionId, - title: localize('maximizePanel', "Maximize Panel Size"), - icon: maximizeIcon - }, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(panelMaximizationSupportedWhen, PanelMaximizedContext.negate()) -}); - -MenuRegistry.appendMenuItem(MenuId.PanelTitle, { - command: { - id: ToggleMaximizedPanelActionId, - title: localize('minimizePanel', "Restore Panel Size"), - icon: restoreIcon - }, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(panelMaximizationSupportedWhen, PanelMaximizedContext) -}); - MenuRegistry.appendMenuItems([ { id: MenuId.LayoutControlMenu, diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index a54437a263347..b1c5637a750c3 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -715,6 +715,18 @@ const registry = Registry.as(ConfigurationExtensions.Con tags: ['accessibility'], enum: ['on', 'off', 'auto'] }, + 'workbench.reduceTransparency': { + type: 'string', + description: localize('workbench.reduceTransparency', "Controls whether the workbench should render with fewer transparency and blur effects for improved performance."), + 'enumDescriptions': [ + localize('workbench.reduceTransparency.on', "Always render without transparency and blur effects."), + localize('workbench.reduceTransparency.off', "Do not reduce transparency and blur effects."), + localize('workbench.reduceTransparency.auto', "Reduce transparency and blur effects based on OS configuration."), + ], + default: 'off', + tags: ['accessibility'], + enum: ['on', 'off', 'auto'] + }, 'workbench.navigationControl.enabled': { 'type': 'boolean', 'default': true, diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b0d814cbf5785..aad7733c40fcf 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -60,6 +60,7 @@ import { IPromptsService } from '../common/promptSyntax/service/promptsService.j import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; +import { UsagesToolContribution } from './tools/usagesTool.js'; import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; @@ -1422,6 +1423,7 @@ registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 4c51c01f5ad0e..9929aa7064e18 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -16,7 +16,7 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { createWorkbenchDialogOptions } from '../../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from '../../../../browser/parts/dialogs/dialog.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; @@ -30,6 +30,7 @@ import { IChatWidgetService } from '../chat.js'; import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -48,7 +49,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); }); } @@ -71,6 +72,7 @@ export class ChatSetup { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IHostService private readonly hostService: IHostService ) { } skipDialog(): void { @@ -176,7 +178,7 @@ export class ChatSetup { disableCloseButton: true, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) - }, this.keybindingService, this.layoutService) + }, this.keybindingService, this.layoutService, this.hostService) )); const { button } = await dialog.show(); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 862445cf50bcc..45a0d7a5724ae 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -9,7 +9,7 @@ import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../ 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'; +import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -37,6 +37,16 @@ export interface IChatTipService { */ readonly onDidDismissTip: Event; + /** + * Fired when the user navigates to a different tip (previous/next). + */ + readonly onDidNavigateTip: Event; + + /** + * Fired when the tip widget is hidden without dismissing the tip. + */ + readonly onDidHideTip: Event; + /** * Fired when tips are disabled. */ @@ -72,10 +82,28 @@ export interface IChatTipService { */ dismissTip(): void; + /** + * Hides the tip widget without permanently dismissing the tip. + * The tip may be shown again in a future session. + */ + hideTip(): void; + /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ disableTips(): Promise; + + /** + * Navigates to the next tip in the catalog without permanently dismissing the current one. + * @param contextKeyService The context key service to evaluate tip eligibility. + */ + navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Navigates to the previous tip in the catalog without permanently dismissing the current one. + * @param contextKeyService The context key service to evaluate tip eligibility. + */ + navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined; } export interface ITipDefinition { @@ -106,18 +134,6 @@ export interface ITipDefinition { * The tip won't be shown if the tool it describes has already been used. */ readonly excludeWhenToolsInvoked?: string[]; - /** - * Tool set reference names. If any tool belonging to one of these tool sets - * has ever been invoked in this workspace, the tip becomes ineligible. - * Unlike {@link excludeWhenToolsInvoked}, this does not require listing - * individual tool IDs, it checks all tools that belong to the named sets. - */ - readonly excludeWhenAnyToolSetToolInvoked?: string[]; - /** - * Tool set reference names where at least one must be registered for the tip to be eligible. - * If none of the listed tool sets are registered, the tip is not shown. - */ - readonly requiresAnyToolSetRegistered?: string[]; /** * If set, exclude this tip when prompt files of the specified type exist in the workspace. */ @@ -210,16 +226,6 @@ const TIP_CATALOG: ITipDefinition[] = [ when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['renderMermaidDiagram'], }, - { - id: 'tip.githubRepo', - message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (@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'), - ), - excludeWhenAnyToolSetToolInvoked: ['github', 'github-pull-request'], - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }, { id: 'tip.subagents', message: localize('tip.subagents', "Tip: Ask the agent to work in parallel to complete large tasks faster."), @@ -265,9 +271,6 @@ export class TipEligibilityTracker extends Disposable { private readonly _pendingModes: Set; private readonly _pendingTools: Set; - /** Tool set reference names monitored via {@link ITipDefinition.excludeWhenAnyToolSetToolInvoked}. */ - private readonly _monitoredToolSets: Set; - private readonly _commandListener = this._register(new MutableDisposable()); private readonly _toolListener = this._register(new MutableDisposable()); @@ -333,13 +336,6 @@ export class TipEligibilityTracker extends Disposable { } } - this._monitoredToolSets = new Set(); - for (const tip of tips) { - for (const name of tip.excludeWhenAnyToolSetToolInvoked ?? []) { - this._monitoredToolSets.add(name); - } - } - // --- Set up command listener (auto-disposes when all seen) -------------- if (this._pendingCommands.size > 0) { @@ -358,44 +354,17 @@ export class TipEligibilityTracker extends Disposable { // --- Set up tool listener (auto-disposes when all seen) ----------------- - if (this._pendingTools.size > 0 || this._monitoredToolSets.size > 0) { + if (this._pendingTools.size > 0) { this._toolListener.value = this._languageModelToolsService.onDidInvokeTool(e => { - let changed = false; - // Track explicit tool IDs if (this._pendingTools.has(e.toolId)) { this._invokedTools.add(e.toolId); this._pendingTools.delete(e.toolId); - changed = true; - } - // Track tools belonging to monitored tool sets - if (this._monitoredToolSets.size > 0 && !this._invokedTools.has(e.toolId)) { - for (const setName of this._monitoredToolSets) { - const toolSet = this._languageModelToolsService.getToolSetByName(setName); - if (toolSet) { - for (const tool of toolSet.getTools()) { - if (tool.id === e.toolId) { - this._invokedTools.add(e.toolId); - // Remove set name from monitoring since ANY tool from the set excludes the tip. - // The tip remains excluded via _invokedTools even after we stop monitoring. - this._monitoredToolSets.delete(setName); - changed = true; - break; - } - } - } - if (changed) { - break; - } - } - } - - if (changed) { this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); } - if (this._pendingTools.size === 0 && this._monitoredToolSets.size === 0) { + if (this._pendingTools.size === 0) { this._toolListener.clear(); } }); @@ -477,30 +446,10 @@ export class TipEligibilityTracker extends Disposable { } } } - if (tip.excludeWhenAnyToolSetToolInvoked) { - for (const setName of tip.excludeWhenAnyToolSetToolInvoked) { - const toolSet = this._languageModelToolsService.getToolSetByName(setName); - if (toolSet) { - for (const tool of toolSet.getTools()) { - if (this._invokedTools.has(tool.id)) { - this._logService.debug('#ChatTips: tip excluded because tool set tool was invoked', tip.id, setName, tool.id); - return true; - } - } - } - } - } if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); return true; } - if (tip.requiresAnyToolSetRegistered) { - const hasAny = tip.requiresAnyToolSetRegistered.some(name => this._languageModelToolsService.getToolSetByName(name)); - if (!hasAny) { - this._logService.debug('#ChatTips: tip excluded because no required tool sets are registered', tip.id); - return true; - } - } return false; } @@ -551,6 +500,12 @@ export class ChatTipService extends Disposable implements IChatTipService { private readonly _onDidDismissTip = this._register(new Emitter()); readonly onDidDismissTip = this._onDidDismissTip.event; + private readonly _onDidNavigateTip = this._register(new Emitter()); + readonly onDidNavigateTip = this._onDidNavigateTip.event; + + private readonly _onDidHideTip = this._register(new Emitter()); + readonly onDidHideTip = this._onDidHideTip.event; + private readonly _onDidDisableTips = this._register(new Emitter()); readonly onDidDisableTips = this._onDidDisableTips.event; @@ -636,6 +591,13 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + hideTip(): void { + this._hasShownRequestTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidHideTip.fire(); + } + async disableTips(): Promise { this._hasShownRequestTip = false; this._shownTip = undefined; @@ -655,6 +617,11 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Only show tips in the main chat panel, not in terminal/editor inline chat + if (!this._isChatLocation(contextKeyService)) { + return undefined; + } + // Check if this is the request that was assigned a tip (for stable rerenders) if (this._tipRequestId === requestId && this._shownTip) { return this._createTip(this._shownTip); @@ -693,6 +660,11 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Only show tips in the main chat panel, not in terminal/editor inline chat + if (!this._isChatLocation(contextKeyService)) { + return undefined; + } + // Return the already-shown tip for stable rerenders if (this._tipRequestId === 'welcome' && this._shownTip) { return this._createTip(this._shownTip); @@ -757,6 +729,40 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(selectedTip); } + navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined { + return this._navigateTip(1, contextKeyService); + } + + navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined { + return this._navigateTip(-1, contextKeyService); + } + + private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { + if (!this._shownTip) { + return undefined; + } + + const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); + if (currentIndex === -1) { + return undefined; + } + + const dismissedIds = new Set(this._getDismissedTipIds()); + for (let i = 1; i < TIP_CATALOG.length; i++) { + const idx = ((currentIndex + direction * i) % TIP_CATALOG.length + TIP_CATALOG.length) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { + this._shownTip = candidate; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.PROFILE, StorageTarget.USER); + const tip = this._createTip(candidate); + this._onDidNavigateTip.fire(tip); + return tip; + } + } + + return undefined; + } + 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()); @@ -769,6 +775,11 @@ export class ChatTipService extends Disposable implements IChatTipService { return true; } + private _isChatLocation(contextKeyService: IContextKeyService): boolean { + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key); + return !location || location === ChatAgentLocation.Chat; + } + private _isCopilotEnabled(): boolean { const defaultChatAgent = this._productService.defaultChatAgent; return !!defaultChatAgent?.chatExtensionId; diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts new file mode 100644 index 0000000000000..aa8e6731069ef --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -0,0 +1,371 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { relativePath } from '../../../../../base/common/resources.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Location, LocationLink } from '../../../../../editor/common/languages.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ISearchService, QueryType, resultIsMatch } from '../../../../services/search/common/search.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; + +export const UsagesToolId = 'vscode_listCodeUsages'; + +interface IUsagesToolInput { + symbol: string; + uri?: string; + filePath?: string; + lineContent: string; +} + +const BaseModelDescription = `Find all usages (references, definitions, and implementations) of a code symbol across the workspace. This tool locates where a symbol is referenced, defined, or implemented. + +Input: +- "symbol": The exact name of the symbol to search for (function, class, method, variable, type, etc.). +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. + +IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. + +If the tool returns an error, retry with corrected input - ensure the file path is correct, the line content matches the actual file content, and the symbol name appears in that line.`; + +export class UsagesTool extends Disposable implements IToolImpl { + + private readonly _onDidUpdateToolData = this._store.add(new Emitter()); + readonly onDidUpdateToolData = this._onDidUpdateToolData.event; + + constructor( + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IModelService private readonly _modelService: IModelService, + @ISearchService private readonly _searchService: ISearchService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { + super(); + + this._store.add(Event.debounce( + this._languageFeaturesService.referenceProvider.onDidChange, + () => { }, + 2000 + )((() => this._onDidUpdateToolData.fire()))); + } + + getToolData(): IToolData { + const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; + + let modelDescription = BaseModelDescription; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + } else if (languageIds.size > 0) { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + } else { + modelDescription += '\n\nNo languages currently have reference providers registered.'; + } + + return { + id: UsagesToolId, + toolReferenceName: 'usages', + canBeReferencedInPrompt: false, + icon: ThemeIcon.fromId(Codicon.references.id), + displayName: localize('tool.usages.displayName', 'List Code Usages'), + userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'), + modelDescription, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'The exact name of the symbol (function, class, method, variable, type, etc.) to find usages of.' + }, + uri: { + type: 'string', + description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + }, + filePath: { + type: 'string', + description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + }, + lineContent: { + type: 'string', + description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.' + } + }, + required: ['symbol', 'lineContent'] + } + }; + } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const input = context.parameters as IUsagesToolInput; + return { + invocationMessage: localize('tool.usages.invocationMessage', 'Analyzing usages of `{0}`', input.symbol), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { + const input = invocation.parameters as IUsagesToolInput; + + // --- resolve URI --- + const uri = this._resolveUri(input); + if (!uri) { + return this._errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + } + + // --- open text model --- + const ref = await this._textModelService.createModelReference(uri); + try { + const model = ref.object.textEditorModel; + + if (!this._languageFeaturesService.referenceProvider.has(model)) { + return this._errorResult(`No reference provider available for this file's language. The usages tool may not support this language.`); + } + + // --- find line containing lineContent --- + const parts = input.lineContent.trim().split(/\s+/); + const lineContent = parts.map(escapeRegExpCharacters).join('\\s+'); + const matches = model.findMatches(lineContent, false, true, false, null, false, 1); + if (matches.length === 0) { + return this._errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); + } + const lineNumber = matches[0].range.startLineNumber; + + // --- find symbol in that line --- + const lineText = model.getLineContent(lineNumber); + const column = this._findSymbolColumn(lineText, input.symbol); + if (column === undefined) { + return this._errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); + } + + const position = new Position(lineNumber, column); + + // --- query references, definitions, implementations in parallel --- + const [definitions, references, implementations] = await Promise.all([ + getDefinitionsAtPosition(this._languageFeaturesService.definitionProvider, model, position, false, token), + getReferencesAtPosition(this._languageFeaturesService.referenceProvider, model, position, false, false, token), + getImplementationsAtPosition(this._languageFeaturesService.implementationProvider, model, position, false, token), + ]); + + if (references.length === 0) { + const result = createToolSimpleTextResult(`No usages found for \`${input.symbol}\`.`); + result.toolResultMessage = new MarkdownString(localize('tool.usages.noResults', 'Analyzed usages of `{0}`, no results', input.symbol)); + return result; + } + + // --- classify and format results with previews --- + const previews = await this._getLinePreviews(input.symbol, references, token); + + const lines: string[] = []; + lines.push(`${references.length} usages of \`${input.symbol}\`:\n`); + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const kind = this._classifyReference(ref, definitions, implementations); + const startLine = Range.lift(ref.range).startLineNumber; + const preview = previews[i]; + if (preview) { + lines.push(``); + lines.push(`\t${preview}`); + lines.push(``); + } else { + lines.push(``); + } + } + + const text = lines.join('\n'); + const result = createToolSimpleTextResult(text); + + result.toolResultMessage = references.length === 1 + ? new MarkdownString(localize('tool.usages.oneResult', 'Analyzed usages of `{0}`, 1 result', input.symbol)) + : new MarkdownString(localize('tool.usages.results', 'Analyzed usages of `{0}`, {1} results', input.symbol, references.length)); + + result.toolResultDetails = references.map((r): Location => ({ uri: r.uri, range: r.range })); + + return result; + } finally { + ref.dispose(); + } + } + + private async _getLinePreviews(symbol: string, references: LocationLink[], token: CancellationToken): Promise<(string | undefined)[]> { + const previews: (string | undefined)[] = new Array(references.length); + + // Build a lookup: (uriString, lineNumber) → index in references array + const lookup = new Map(); + const needSearch = new ResourceSet(); + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const lineNumber = Range.lift(ref.range).startLineNumber; + + // Try already-open models first + const existingModel = this._modelService.getModel(ref.uri); + if (existingModel) { + previews[i] = existingModel.getLineContent(lineNumber).trim(); + } else { + lookup.set(`${ref.uri.toString()}:${lineNumber}`, i); + needSearch.add(ref.uri); + } + } + + if (needSearch.size === 0 || token.isCancellationRequested) { + return previews; + } + + // Use ISearchService to search for the symbol name, restricted to the + // referenced files. This is backed by ripgrep for file:// URIs. + try { + // Build includePattern from workspace-relative paths + const folders = this._workspaceContextService.getWorkspace().folders; + const relativePaths: string[] = []; + for (const uri of needSearch) { + const folder = this._workspaceContextService.getWorkspaceFolder(uri); + if (folder) { + const rel = relativePath(folder.uri, uri); + if (rel) { + relativePaths.push(rel); + } + } + } + + if (relativePaths.length > 0) { + const includePattern: Record = {}; + if (relativePaths.length === 1) { + includePattern[relativePaths[0]] = true; + } else { + includePattern[`{${relativePaths.join(',')}}`] = true; + } + + const searchResult = await this._searchService.textSearch( + { + type: QueryType.Text, + contentPattern: { pattern: escapeRegExpCharacters(symbol), isRegExp: true, isWordMatch: true }, + folderQueries: folders.map(f => ({ folder: f.uri })), + includePattern, + }, + token, + ); + + for (const fileMatch of searchResult.results) { + if (!fileMatch.results) { + continue; + } + for (const textMatch of fileMatch.results) { + if (!resultIsMatch(textMatch)) { + continue; + } + for (const range of textMatch.rangeLocations) { + const lineNumber = range.source.startLineNumber + 1; // 0-based → 1-based + const key = `${fileMatch.resource.toString()}:${lineNumber}`; + const idx = lookup.get(key); + if (idx !== undefined) { + previews[idx] = textMatch.previewText.trim(); + lookup.delete(key); + } + } + } + } + } + } catch { + // search might fail, leave remaining previews as undefined + } + + return previews; + } + + private _resolveUri(input: IUsagesToolInput): URI | undefined { + if (input.uri) { + return URI.parse(input.uri); + } + if (input.filePath) { + const folders = this._workspaceContextService.getWorkspace().folders; + if (folders.length === 1) { + return folders[0].toResource(input.filePath); + } + // try each folder, return the first + for (const folder of folders) { + return folder.toResource(input.filePath); + } + } + return undefined; + } + + private _findSymbolColumn(lineText: string, symbol: string): number | undefined { + // use word boundary matching to avoid partial matches + const pattern = new RegExp(`\\b${escapeRegExpCharacters(symbol)}\\b`); + const match = pattern.exec(lineText); + if (match) { + return match.index + 1; // 1-based column + } + return undefined; + } + + private _classifyReference(ref: LocationLink, definitions: LocationLink[], implementations: LocationLink[]): string { + if (definitions.some(d => this._overlaps(ref, d))) { + return 'definition'; + } + if (implementations.some(d => this._overlaps(ref, d))) { + return 'implementation'; + } + return 'reference'; + } + + private _overlaps(a: LocationLink, b: LocationLink): boolean { + if (a.uri.toString() !== b.uri.toString()) { + return false; + } + return Range.areIntersectingOrTouching(a.range, b.range); + } + + private _errorResult(message: string): IToolResult { + const result = createToolSimpleTextResult(message); + result.toolResultMessage = new MarkdownString(message); + return result; + } +} + +export class UsagesToolContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.usagesTool'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const usagesTool = this._store.add(instantiationService.createInstance(UsagesTool)); + + let registration: IDisposable | undefined; + const registerUsagesTool = () => { + registration?.dispose(); + toolsService.flushToolUpdates(); + const toolData = usagesTool.getToolData(); + registration = toolsService.registerTool(toolData, usagesTool); + }; + registerUsagesTool(); + this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool)); + this._store.add({ dispose: () => registration?.dispose() }); + } +} 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 7a5ba17dcaa9a..7ccef8a28bed8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -13,10 +13,11 @@ import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatTip, IChatTipService } from '../../chatTipService.js'; @@ -30,6 +31,7 @@ export class ChatTipContentPart extends Disposable { public readonly onDidHide = this._onDidHide.event; private readonly _renderedContent = this._register(new MutableDisposable()); + private readonly _toolbar = this._register(new MutableDisposable()); private readonly _inChatTipContextKey: IContextKey; @@ -41,6 +43,7 @@ export class ChatTipContentPart extends Disposable { @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IMenuService private readonly _menuService: IMenuService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -66,6 +69,14 @@ export class ChatTipContentPart extends Disposable { } })); + this._register(this._chatTipService.onDidNavigateTip(tip => { + this._renderTip(tip); + })); + + this._register(this._chatTipService.onDidHideTip(() => { + this._onDidHide.fire(); + })); + this._register(this._chatTipService.onDidDisableTips(() => { this._onDidHide.fire(); })); @@ -93,10 +104,22 @@ export class ChatTipContentPart extends Disposable { private _renderTip(tip: IChatTip): void { dom.clearNode(this.domNode); + this._toolbar.clear(); + this.domNode.appendChild(renderIcon(Codicon.lightbulb)); const markdownContent = this._renderer.render(tip.content); this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); + + // Toolbar with previous, next, and dismiss actions via MenuWorkbenchToolBar + const toolbarContainer = $('.chat-tip-toolbar'); + this._toolbar.value = this._instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, MenuId.ChatTipToolbar, { + menuOptions: { + shouldForwardArgs: true, + }, + }); + this.domNode.appendChild(toolbarContainer); + const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip"); const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value); const ariaLabel = hasLink @@ -107,6 +130,94 @@ export class ChatTipContentPart extends Disposable { } } +//#region Tip toolbar actions + +registerAction2(class PreviousTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.previousTip', + title: localize2('chatTip.previous', "Previous Tip"), + icon: Codicon.chevronLeft, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatTipService = accessor.get(IChatTipService); + const contextKeyService = accessor.get(IContextKeyService); + chatTipService.navigateToPreviousTip(contextKeyService); + } +}); + +registerAction2(class NextTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.nextTip', + title: localize2('chatTip.next', "Next Tip"), + icon: Codicon.chevronRight, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatTipService = accessor.get(IChatTipService); + const contextKeyService = accessor.get(IContextKeyService); + chatTipService.navigateToNextTip(contextKeyService); + } +}); + +registerAction2(class DismissTipToolbarAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.dismissTipToolbar', + title: localize2('chatTip.dismissButton', "Dismiss Tip"), + icon: Codicon.check, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 3, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).dismissTip(); + } +}); + +registerAction2(class CloseTipToolbarAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.closeTip', + title: localize2('chatTip.close', "Close Tips"), + icon: Codicon.close, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 4, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).hideTip(); + } +}); + +//#endregion + //#region Tip context menu actions registerAction2(class DismissTipAction extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 98c05a0bc8cfa..4a6cbd669af0f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -3,484 +3,397 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-question-carousel-container { - margin: 8px 0; - border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; - display: flex; - flex-direction: column; - overflow: hidden; - container-type: inline-size; -} - -.chat-question-carousel-summary { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 16px; +/* question carousel - this is above edits and todos */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { + display: none; } -.chat-question-summary-item { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: baseline; - gap: 0; - font-size: var(--vscode-chat-font-size-body-s); -} - -.chat-question-summary-label { - color: var(--vscode-descriptionForeground); - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-label::after { - content: ': '; - white-space: pre; -} - -.chat-question-summary-answer-title { - color: var(--vscode-foreground); - font-weight: 600; - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-answer-desc { - color: var(--vscode-foreground); - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-skipped { - color: var(--vscode-descriptionForeground); - font-style: italic; - font-size: var(--vscode-chat-font-size-body-s); -} - -.chat-question-header-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - min-width: 0; - padding-bottom: 5px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); -} - -.chat-question-header-row .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; -} - -/* Close button container in header */ -.chat-question-close-container { - flex-shrink: 0; -} - -.chat-question-close-container .monaco-button.chat-question-close { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; - background: transparent !important; - color: var(--vscode-foreground) !important; -} - -.chat-question-close-container .monaco-button.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; -} - -/* Footer row with step indicator and navigation */ -.chat-question-footer-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 16px; - border-top: 1px solid var(--vscode-chat-requestBorder); - background: var(--vscode-chat-requestBackground); -} - -/* Step indicator (e.g., "2/4") */ -.chat-question-step-indicator { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); -} - -.chat-question-carousel-nav { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - margin-left: auto; -} - -.chat-question-nav-arrows { - display: flex; - align-items: center; - gap: 4px; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; -} - -/* Secondary buttons (prev, next) use gray secondary background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { - background: var(--vscode-button-secondaryBackground) !important; - color: var(--vscode-button-secondaryForeground) !important; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { - background: var(--vscode-button-secondaryHoverBackground) !important; -} - -/* Submit button (next on last question) uses primary background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { - background: var(--vscode-button-background) !important; - color: var(--vscode-button-foreground) !important; - width: auto; - min-width: auto; - padding: 0 8px; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { - background: var(--vscode-button-hoverBackground) !important; -} - -/* Close button uses transparent background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { - background: transparent !important; - color: var(--vscode-foreground) !important; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; +/* input specific styling */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { + margin: 0; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-radius: 4px; } -.chat-question-carousel-content { +/* general questions styling */ +.interactive-session .chat-question-carousel-container { + margin: 8px 0; + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: 4px; display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; overflow: hidden; + container-type: inline-size; } -.chat-question-title { - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; -} - -.chat-question-title-main { - font-weight: 500; -} - -.chat-question-title-subtitle { - font-weight: normal; - color: var(--vscode-descriptionForeground); -} - -.chat-question-message { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - margin: 0; - line-height: 1.5; -} +/* container and header */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { + width: 100%; + position: relative; -.chat-question-message p { - margin-top: 0; - margin-bottom: 4px; + .chat-question-carousel-content { + display: flex; + flex-direction: column; + background: var(--vscode-chat-requestBackground); + padding: 8px 16px 10px 16px; + overflow: hidden; + + .chat-question-header-row { + display: flex; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding-bottom: 5px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); + + .chat-question-title { + flex: 1; + min-width: 0; + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; + + .chat-question-title-main { + font-weight: 500; + } + + .chat-question-title-subtitle { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } + + .chat-question-close-container { + flex-shrink: 0; + + .monaco-button.chat-question-close { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent !important; + color: var(--vscode-foreground) !important; + } + + .monaco-button.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + } + } + } } -.chat-question-input-container { +/* questions list and freeform area */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-input-container { display: flex; flex-direction: column; margin-top: 4px; min-width: 0; -} -/* List-style selection UI (similar to QuickPick) */ -.chat-question-list { - display: flex; - flex-direction: column; - gap: 3px; - outline: none; - padding: 4px 0; -} - -.chat-question-list:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.chat-question-list-item { - display: flex; - align-items: center; - gap: 8px; - padding: 3px 8px; - cursor: pointer; - border-radius: 3px; - user-select: none; -} - -.chat-question-list-item:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.interactive-input-part .chat-question-carousel-widget-container .chat-question-input-container { + /* some hackiness to get the focus looking right */ .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; } -} - -/* Single-select: highlight entire row when selected */ -.chat-question-list-item.selected { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); -} + .chat-question-list:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } -.chat-question-list:focus-within .chat-question-list-item.selected { - outline-width: 1px; - outline-style: solid; - outline-offset: -1px; - outline-color: var(--vscode-focusBorder); -} + .chat-question-list:focus-within .chat-question-list-item.selected { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); + } -.chat-question-list-item.selected:hover { - background-color: var(--vscode-list-activeSelectionBackground); -} + .chat-question-list { + display: flex; + flex-direction: column; + gap: 3px; + outline: none; + padding: 4px 0; + + .chat-question-list-item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 8px; + cursor: pointer; + border-radius: 3px; + user-select: none; + + .chat-question-list-indicator { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + /* Label in list item */ + .chat-question-list-label { + font-size: var(--vscode-chat-font-size-body-s); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .chat-question-list-label-title { + font-weight: 600; + } + + .chat-question-list-label-desc { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } + + .chat-question-list-item:hover { + background-color: var(--vscode-list-hoverBackground); + } + + /* Single-select: highlight entire row when selected */ + .chat-question-list-item.selected { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + + .chat-question-label { + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-list-label-desc { + color: var(--vscode-list-activeSelectionForeground); + opacity: 0.8; + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-list-number { + background-color: transparent; + color: var(--vscode-list-activeSelectionForeground); + border-color: var(--vscode-list-activeSelectionForeground); + border-bottom-color: var(--vscode-list-activeSelectionForeground); + box-shadow: none; + } + } + + .chat-question-list-item.selected:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } + + /* Checkbox for multi-select */ + .chat-question-list-checkbox { + flex-shrink: 0; + } + + .chat-question-list-checkbox.monaco-custom-toggle { + margin-right: 0; + } + } -/* todo: change to use keybinding service so we don't have to recreate this */ -.chat-question-list-number, -.chat-question-freeform-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 14px; - padding: 0px 4px; - border-style: solid; - border-width: 1px; - border-radius: 3px; - font-size: 11px; - font-weight: normal; - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); - flex-shrink: 0; -} + .chat-question-freeform { + margin-left: 8px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .chat-question-freeform-number { + height: fit-content; + } + + /* this is probably legacy too */ + .chat-question-freeform-label { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } + + .chat-question-freeform-textarea { + width: 100%; + min-height: 24px; + max-height: 200px; + padding: 3px 8px; + border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: 4px; + resize: none; + font-family: var(--vscode-chat-font-family, inherit); + font-size: var(--vscode-chat-font-size-body-s); + box-sizing: border-box; + overflow-y: hidden; + align-content: center; + } + + .chat-question-freeform-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); + } + + .chat-question-freeform-textarea::placeholder { + color: var(--vscode-input-placeholderForeground); + } -.chat-question-freeform-number { - height: fit-content; -} + } -.chat-question-list-item.selected .chat-question-list-number { - background-color: transparent; - color: var(--vscode-list-activeSelectionForeground); - border-color: var(--vscode-list-activeSelectionForeground); - border-bottom-color: var(--vscode-list-activeSelectionForeground); - box-shadow: none; + /* todo: change to use keybinding service so we don't have to recreate this */ + .chat-question-list-number, + .chat-question-freeform-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + padding: 0px 4px; + border-style: solid; + border-width: 1px; + border-radius: 3px; + font-size: 11px; + font-weight: normal; + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + border-color: var(--vscode-keybindingLabel-border); + border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); + box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + flex-shrink: 0; + } } -/* Selection indicator (checkmark) for single select - positioned on right */ -.chat-question-list-indicator { - width: 16px; - height: 16px; +/* footer with step indicator and nav buttons */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-footer-row { display: flex; + justify-content: space-between; align-items: center; - justify-content: center; - flex-shrink: 0; - margin-left: auto; -} + padding: 4px 16px; + border-top: 1px solid var(--vscode-chat-requestBorder); + background: var(--vscode-chat-requestBackground); -.chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); -} + .chat-question-step-indicator { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } -.chat-question-list-item.selected .chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground); -} + .chat-question-carousel-nav { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; + } -/* Checkbox for multi-select */ -.chat-question-list-checkbox { - flex-shrink: 0; -} + .chat-question-nav-arrows { + display: flex; + align-items: center; + gap: 4px; + } -.chat-question-list-checkbox.monaco-custom-toggle { - margin-right: 0; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + } + /* Secondary buttons (prev, next) use gray secondary background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { + background: var(--vscode-button-secondaryBackground) !important; + color: var(--vscode-button-secondaryForeground) !important; + } -/* Label in list item */ -.chat-question-list-label { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { + background: var(--vscode-button-secondaryHoverBackground) !important; + } -.chat-question-list-label-title { - font-weight: 600; -} + /* Submit button (next on last question) uses primary background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { + background: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; + width: auto; + min-width: auto; + padding: 0 8px; + } -.chat-question-list-label-desc { - font-weight: normal; - color: var(--vscode-descriptionForeground); -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { + background: var(--vscode-button-hoverBackground) !important; + } -.chat-question-list-item.selected .chat-question-list-label { - color: var(--vscode-list-activeSelectionForeground); -} + /* Close button uses transparent background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { + background: transparent !important; + color: var(--vscode-foreground) !important; + } -.chat-question-list-item.selected .chat-question-list-label-desc { - color: var(--vscode-list-activeSelectionForeground); - opacity: 0.8; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } -/* Legacy styles for backwards compatibility (to be removed) */ -.chat-question-options { - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; } -.chat-question-option { +/* summary (after finished) */ +.interactive-session .chat-question-carousel-summary { display: flex; - align-items: flex-start; + flex-direction: column; gap: 8px; - padding: 3px 0; - min-width: 0; -} - -.chat-question-option input[type="radio"], -.chat-question-option input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - width: 18px; - height: 18px; - min-width: 18px; - min-height: 18px; - flex-shrink: 0; - margin: 0; - border: 1px solid var(--vscode-checkbox-border); - background-color: var(--vscode-checkbox-background); - border-radius: 3px; - cursor: pointer; - position: relative; - outline: none; -} - -.chat-question-option input[type="radio"] { - border-radius: 50%; -} - -.chat-question-option input[type="radio"]:checked, -.chat-question-option input[type="checkbox"]:checked { - background-color: var(--vscode-checkbox-selectBackground, var(--vscode-checkbox-background)); -} - -.chat-question-option input[type="checkbox"]:checked::after { - content: ''; - position: absolute; - top: 3px; - left: 6px; - width: 4px; - height: 8px; - border: solid var(--vscode-checkbox-foreground); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.chat-question-option input[type="radio"]:checked::after { - content: ''; - position: absolute; - top: 4px; - left: 4px; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--vscode-checkbox-foreground); -} - -.chat-question-option input[type="radio"]:focus, -.chat-question-option input[type="checkbox"]:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 1px; -} - -.chat-question-option input[type="radio"]:hover, -.chat-question-option input[type="checkbox"]:hover { - background-color: var(--vscode-inputOption-hoverBackground); -} - -.chat-question-option label { - flex: 1; - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - cursor: pointer; - word-wrap: break-word; - overflow-wrap: break-word; - min-width: 0; -} + padding: 8px 16px; + margin-bottom: 4px; + .chat-question-summary-item { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 0; + font-size: var(--vscode-chat-font-size-body-s); + } -.chat-question-freeform { - margin-left: 8px; - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -} + .chat-question-summary-label { + color: var(--vscode-descriptionForeground); + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-label { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); -} + .chat-question-summary-label::after { + content: ': '; + white-space: pre; + } -.chat-question-freeform-textarea { - width: 100%; - min-height: 24px; - max-height: 200px; - padding: 3px 8px; - border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border-radius: 4px; - resize: none; - font-family: var(--vscode-chat-font-family, inherit); - font-size: var(--vscode-chat-font-size-body-s); - box-sizing: border-box; - overflow-y: hidden; - align-content: center; -} + .chat-question-summary-answer-title { + color: var(--vscode-foreground); + font-weight: 600; + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-textarea:focus { - outline: 1px solid var(--vscode-focusBorder); - border-color: var(--vscode-focusBorder); -} + .chat-question-summary-answer-desc { + color: var(--vscode-foreground); + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-textarea::placeholder { - color: var(--vscode-input-placeholderForeground); + .chat-question-summary-skipped { + color: var(--vscode-descriptionForeground); + font-style: italic; + font-size: var(--vscode-chat-font-size-body-s); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 3077e67d2b5d3..67ecd94b482f8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -16,7 +16,6 @@ font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); position: relative; - overflow: hidden; } .interactive-item-container .chat-tip-widget .codicon-lightbulb { @@ -37,6 +36,52 @@ margin: 0; } +.interactive-item-container .chat-tip-widget .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar { + opacity: 0; + pointer-events: none; +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar { + position: absolute; + top: -15px; + right: 10px; + height: 26px; + line-height: 26px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-medium); + z-index: 100; + transition: opacity 0.1s ease-in-out; +} + +.interactive-item-container .chat-tip-widget:hover .chat-tip-toolbar, +.interactive-item-container .chat-tip-widget:focus-within .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget:hover .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget:focus-within .chat-tip-toolbar { + opacity: 1; + pointer-events: auto; +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item { + height: 24px; + width: 24px; + margin: 1px 2px; +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label { + color: var(--vscode-descriptionForeground); +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + .chat-getting-started-tip-container { margin-bottom: -4px; /* Counter the flex gap */ width: 100%; @@ -57,7 +102,7 @@ font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); - overflow: hidden; + position: relative; } .chat-getting-started-tip-container .chat-tip-widget a { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index da0056dc9df4d..fe9b64f5b8578 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1935,6 +1935,17 @@ export class ChatWidget extends Disposable implements IChatWidget { this.container.setAttribute('data-session-id', model.sessionId); this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); + // mark any question carousels as used on reload + for (const request of model.getRequests()) { + if (request.response) { + for (const part of request.response.entireResponse.value) { + if (part.kind === 'questionCarousel' && !part.isUsed) { + part.isUsed = true; + } + } + } + } + // Pass input model reference to input part for state syncing this.inputPart.setInputModel(model.inputModel, model.getRequests().length === 0); this.listWidget.setViewModel(this.viewModel); 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 7e106073f2963..8ed7dc44ac561 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1077,23 +1077,6 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } -/* question carousel - this is above edits and todos */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { - width: 100%; - position: relative; -} - -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { - display: none; -} - -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { - margin: 0px; - border: 1px solid var(--vscode-input-border, transparent); - background-color: var(--vscode-editor-background); - border-radius: 4px; -} - /* Chat Todo List Widget Container - mirrors chat-editing-session styling */ .interactive-session .interactive-input-part > .chat-todo-list-widget-container { margin-bottom: -4px; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index d9bf9d1fe5a59..c07de21aed866 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -2311,6 +2311,17 @@ export class ChatModel extends Disposable implements IChatModel { modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; } + // Mark question carousels as used after + // deserialization. After a reload, the extension is no longer listening for + // their responses, so they cannot be interacted with. + if (raw.response) { + for (const part of raw.response) { + if (hasKey(part, { kind: true }) && (part.kind === 'questionCarousel')) { + part.isUsed = true; + } + } + } + request.response = new ChatResponseModel({ responseContent: raw.response ?? [new MarkdownString(raw.response)], session: this, diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index 96fb13cb66942..a17eb174f3886 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -327,6 +327,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri const tools: IToolData[] = []; const toolSets: IToolSet[] = []; + const missingToolNames: string[] = []; for (const toolName of toolSet.tools) { const toolObj = languageModelToolsService.getToolByName(toolName); @@ -339,7 +340,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri toolSets.push(toolSetObj); continue; } - extension.collector.warn(`Tool set '${toolSet.name}' CANNOT find tool or tool set by name: ${toolName}`); + missingToolNames.push(toolName); } if (toolSets.length === 0 && tools.length === 0) { @@ -373,6 +374,30 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri toolSets.forEach(toolSet => store.add(obj.addToolSet(toolSet, tx))); }); + // Listen for late-registered tools that weren't available at contribution time + if (missingToolNames.length > 0) { + const pending = new Set(missingToolNames); + const listener = store.add(languageModelToolsService.onDidChangeTools(() => { + for (const toolName of pending) { + const toolObj = languageModelToolsService.getToolByName(toolName); + if (toolObj) { + store.add(obj.addTool(toolObj)); + pending.delete(toolName); + } else { + const toolSetObj = languageModelToolsService.getToolSetByName(toolName); + if (toolSetObj) { + store.add(obj.addToolSet(toolSetObj)); + pending.delete(toolName); + } + } + } + if (pending.size === 0) { + // done + store.delete(listener); + } + })); + } + this._registrationDisposables.set(toToolSetKey(extension.description.identifier, toolSet.name), store); } } diff --git a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts index 20de711820c65..53617f61395cd 100644 --- a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts +++ b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts @@ -7,9 +7,12 @@ * This is a subset of the types export from jsonTypes.d.ts in @vscode/prompt-tsx. * It's just the types needed to stringify prompt-tsx tool results. * It should be kept in sync with the types in that file. + * + * Note: do NOT use `declare` with const enums, esbuild doesn't inline them. + * See https://github.com/evanw/esbuild/issues/4394 */ -export declare const enum PromptNodeType { +export const enum PromptNodeType { Piece = 1, Text = 2 } @@ -23,7 +26,7 @@ export interface TextJSON { * less descriptive than the actual constructor, as we only care to preserve * the element data that the renderer cares about. */ -export declare const enum PieceCtorKind { +export const enum PieceCtorKind { BaseChatMessage = 1, Other = 2, ImageChatMessage = 3 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 873bf370b9219..e923a89075113 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -19,9 +19,9 @@ import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../bro 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 { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { @@ -142,6 +142,28 @@ suite('ChatTipService', () => { assert.strictEqual(tip, undefined, 'Should not return a tip when tips setting is disabled'); }); + test('returns undefined when location is terminal', () => { + const service = createService(); + const now = Date.now(); + + const terminalContextKeyService = new MockContextKeyServiceWithRulesMatching(); + terminalContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.Terminal); + + const tip = service.getNextTip('request-1', now + 1000, terminalContextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip in terminal inline chat'); + }); + + test('returns undefined when location is editor inline', () => { + const service = createService(); + const now = Date.now(); + + const editorContextKeyService = new MockContextKeyServiceWithRulesMatching(); + editorContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.EditorInline); + + const tip = service.getNextTip('request-1', now + 1000, editorContextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip in editor inline chat'); + }); + test('old requests do not consume the session tip allowance', () => { const service = createService(); const now = Date.now(); @@ -613,74 +635,6 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); - test('excludes tip when requiresAnyToolSetRegistered tool sets are not registered', () => { - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when no required tool sets are registered'); - }); - - test('excludes tip when a tool belonging to a monitored tool set has been invoked', () => { - const mockToolsService = createMockToolsService(); - const toolInSet: IToolData = { id: 'mcp_github_get_me', source: ToolDataSource.Internal, displayName: 'Get Me', modelDescription: 'Get Me' }; - mockToolsService.addRegisteredToolSetName('github', [toolInSet]); - - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - excludeWhenAnyToolSetToolInvoked: ['github'], - }; - - 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 any tool set tool is invoked'); - - mockToolsService.fireOnDidInvokeTool({ toolId: 'mcp_github_get_me', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); - - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after a tool from the monitored tool set is invoked'); - }); - - test('does not exclude tip when at least one requiresAnyToolSetRegistered tool set is registered', () => { - const mockToolsService = createMockToolsService(); - mockToolsService.addRegisteredToolSetName('github'); - - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }; - - 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 when at least one required tool set is registered'); - }); - test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts new file mode 100644 index 0000000000000..e0e20ec03f629 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { DefinitionProvider, ImplementationProvider, Location, ReferenceProvider } from '../../../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; +import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js'; +import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +function getTextContent(result: IToolResult): string { + const part = result.content.find((p): p is IToolResultTextPart => p.kind === 'text'); + return part?.value ?? ''; +} + +suite('UsagesTool', () => { + + const disposables = new DisposableStore(); + let langFeatures: LanguageFeaturesService; + + const testUri = URI.parse('file:///test/file.ts'); + const testContent = [ + 'import { MyClass } from "./myClass";', + '', + 'function doSomething() {', + '\tconst instance = new MyClass();', + '\tinstance.run();', + '}', + ].join('\n'); + + function createMockModelService(models?: ITextModel[]): IModelService { + return { + _serviceBrand: undefined, + getModel: (uri: URI) => models?.find(m => m.uri.toString() === uri.toString()) ?? null, + } as unknown as IModelService; + } + + function createMockSearchService(searchImpl?: (query: ITextQuery) => ISearchComplete): ISearchService { + return { + _serviceBrand: undefined, + textSearch: async (query: ITextQuery) => searchImpl?.(query) ?? { results: [], messages: [] }, + } as unknown as ISearchService; + } + + function createMockTextModelService(model: ITextModel): ITextModelService { + return { + _serviceBrand: undefined, + createModelReference: async () => ({ + object: { textEditorModel: model }, + dispose: () => { }, + }), + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + } + + function createMockWorkspaceService(): IWorkspaceContextService { + const folderUri = URI.parse('file:///test'); + const folder = { + uri: folderUri, + toResource: (relativePath: string) => URI.parse(`file:///test/${relativePath}`), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (uri: URI) => { + if (uri.toString().startsWith(folderUri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + function createInvocation(parameters: Record): IToolInvocation { + return { parameters } as unknown as IToolInvocation; + } + + const noopCountTokens = async () => 0; + const noopProgress: ToolProgress = { report() { } }; + + function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { + return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + } + + setup(() => { + langFeatures = new LanguageFeaturesService(); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolData', () => { + + test('reports no providers when none registered', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + const data = tool.getToolData(); + assert.strictEqual(data.id, UsagesToolId); + assert.ok(data.modelDescription.includes('No languages currently have reference providers')); + }); + + test('lists registered language ids', () => { + const model = disposables.add(createTextModel('', 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('typescript')); + }); + + test('reports all languages for wildcard', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('all languages')); + }); + }); + + suite('invoke', () => { + + test('returns error when no uri or filePath provided', async () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', lineContent: 'MyClass' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Provide either')); + }); + + test('returns error when line content not found', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'nonexistent line' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find line content')); + }); + + test('returns error when symbol not found in line', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'NotHere', uri: testUri.toString(), lineContent: 'function doSomething' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find symbol')); + }); + + test('finds references and classifies them with usage tags', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + + const refProvider: ReferenceProvider = { + provideReferences: (_model: ITextModel): Location[] => [ + { uri: testUri, range: new Range(1, 10, 1, 17) }, + { uri: testUri, range: new Range(4, 23, 4, 30) }, + { uri: otherUri, range: new Range(5, 1, 5, 8) }, + ] + }; + const defProvider: DefinitionProvider = { + provideDefinition: () => [{ uri: testUri, range: new Range(1, 10, 1, 17) }] + }; + const implProvider: ImplementationProvider = { + provideImplementation: () => [{ uri: otherUri, range: new Range(5, 1, 5, 8) }] + }; + + disposables.add(langFeatures.referenceProvider.register('typescript', refProvider)); + disposables.add(langFeatures.definitionProvider.register('typescript', defProvider)); + disposables.add(langFeatures.implementationProvider.register('typescript', implProvider)); + + // Model is open for testUri so IModelService returns it; otherUri needs search + const searchCalled: ITextQuery[] = []; + const searchService = createMockSearchService(query => { + searchCalled.push(query); + const fileMatch = new FileMatch(otherUri); + fileMatch.results = [new TextSearchMatch( + 'export class MyClass implements IMyClass {', + new OneLineRange(4, 0, 7) // 0-based line 4 = 1-based line 5 + )]; + return { results: [fileMatch], messages: [] }; + }); + const modelService = createMockModelService([model]); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + + // Check overall structure + assert.ok(text.includes('3 usages of `MyClass`')); + + // Check usage tag format + assert.ok(text.includes(``)); + assert.ok(text.includes(``)); + assert.ok(text.includes(``)); + + // Check that previews from open model are included (testUri lines) + assert.ok(text.includes('import { MyClass } from "./myClass"')); + assert.ok(text.includes('const instance = new MyClass()')); + + // Check that preview from search service is included (otherUri) + assert.ok(text.includes('export class MyClass implements IMyClass {')); + + // Check closing tags + assert.ok(text.includes('')); + + // Verify search service was called for the non-open file + assert.strictEqual(searchCalled.length, 1); + assert.ok(searchCalled[0].contentPattern.pattern.includes('MyClass')); + assert.ok(searchCalled[0].contentPattern.isWordMatch); + }); + + test('uses self-closing tag when no preview available', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: otherUri, range: new Range(10, 5, 10, 12) }, + ] + })); + + // Search returns no results for this file (symbol renamed/aliased) + const searchService = createMockSearchService(() => ({ results: [], messages: [] })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes(``)); + }); + + test('does not call search service for files already open in model service', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: testUri, range: new Range(1, 10, 1, 17) }, + ] + })); + + let searchCalled = false; + const searchService = createMockSearchService(() => { + searchCalled = true; + return { results: [], messages: [] }; + }); + const modelService = createMockModelService([model]); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + assert.strictEqual(searchCalled, false, 'search service should not be called when all files are open'); + }); + + test('handles whitespace normalization in lineContent', async () => { + const content = 'function doSomething(x: number) {}'; + const model = disposables.add(createTextModel(content, 'typescript', undefined, testUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: testUri, range: new Range(1, 12, 1, 23) }, + ] + })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'doSomething', uri: testUri.toString(), lineContent: 'function doSomething(x: number)' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + }); + + test('resolves filePath via workspace folders', async () => { + const fileUri = URI.parse('file:///test/src/file.ts'); + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, fileUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: fileUri, range: new Range(1, 10, 1, 17) }, + ] + })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', filePath: 'src/file.ts', lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index 934d86152e6b7..c0800d4b46d91 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -167,6 +167,51 @@ suite('ChatModel', () => { assert.strictEqual(request1.response!.shouldBeRemovedOnSend, undefined); }); + test('deserialization marks unused question carousels as used', async () => { + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'test-session', + creationDate: Date.now(), + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + requests: [{ + requestId: 'req1', + message: { text: 'hello', parts: [] }, + variableData: { variables: [] }, + response: [ + { value: 'some text', isTrusted: false }, + { + kind: 'questionCarousel' as const, + questions: [{ id: 'q1', title: 'Question 1', type: 'text' as const }], + allowSkip: true, + resolveId: 'resolve1', + isUsed: false, + }, + ], + modelState: { value: 2 /* ResponseModelState.Cancelled */, completedAt: Date.now() }, + }], + responderUsername: 'bot', + }; + + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + const requests = model.getRequests(); + assert.strictEqual(requests.length, 1); + const response = requests[0].response!; + + // The question carousel should be marked as used after deserialization + const carouselPart = response.response.value.find(p => p.kind === 'questionCarousel'); + assert.ok(carouselPart); + assert.strictEqual(carouselPart.isUsed, true); + + // The response should be complete (not stuck in NeedsInput) + assert.strictEqual(response.isComplete, true); + }); + test('inputModel.toJSON filters extension-contributed contexts', async function () { const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 9b0241b7a2dd3..366bca29f921c 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -18,7 +18,7 @@ import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../ import { ICommandService, ICommandHandler, CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Event } from '../../../../base/common/event.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { Disposable, IDisposable, toDisposable, dispose, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { toDisposable, dispose, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IconLabel } from '../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ActionRunner, IAction } from '../../../../base/common/actions.js'; @@ -320,7 +320,8 @@ class PrivacyColumn implements ITableColumn { } interface IActionBarTemplateData { - elementDisposable: IDisposable; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; container: HTMLElement; label: IconLabel; button?: Button; @@ -338,7 +339,7 @@ interface ActionBarCell { editId: TunnelEditId; } -class ActionBarRenderer extends Disposable implements ITableRenderer { +class ActionBarRenderer implements ITableRenderer { readonly templateId = 'actionbar'; private inputDone?: (success: boolean, finishEditing: boolean) => void; private _actionRunner: ActionRunner | undefined; @@ -353,8 +354,6 @@ class ActionBarRenderer extends Disposable implements ITableRenderer { + templateData.elementDisposables.add(templateData.button.onDidClick(() => { this.commandService.executeCommand(ForwardPortAction.INLINE_ID); })); } @@ -464,10 +467,8 @@ class ActionBarRenderer extends Disposable implements ITableRenderer action.id.toLowerCase().indexOf('label') >= 0); @@ -489,12 +490,13 @@ class ActionBarRenderer extends Disposable implements ITableRenderer { + templateData.elementDisposables.add(toDisposable(() => { done(false, false); - }); + })); } disposeElement(element: ActionBarCell, index: number, templateData: IActionBarTemplateData) { - templateData.elementDisposable.dispose(); + templateData.elementDisposables.clear(); } disposeTemplate(templateData: IActionBarTemplateData): void { - templateData.label.dispose(); - templateData.actionBar.dispose(); - templateData.elementDisposable.dispose(); - templateData.button?.dispose(); + templateData.templateDisposables.dispose(); } } @@ -1817,4 +1816,3 @@ MenuRegistry.appendMenuItem(MenuId.TunnelLocalAddressInline, ({ })); registerColor('ports.iconRunningProcessForeground', STATUS_BAR_REMOTE_ITEM_BACKGROUND, nls.localize('portWithRunningProcess.foreground', "The color of the icon for a port that has an associated running process.")); - diff --git a/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts b/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts index 729fc617379e3..e21db81253e1c 100644 --- a/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts +++ b/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts @@ -6,8 +6,6 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; @@ -19,10 +17,8 @@ import { DialogService } from '../../../services/dialogs/common/dialogService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { createNativeAboutDialogDetails } from '../../../../platform/dialogs/electron-browser/dialog.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; export class DialogHandlerContribution extends Disposable implements IWorkbenchContribution { @@ -38,19 +34,15 @@ export class DialogHandlerContribution extends Disposable implements IWorkbenchC @IConfigurationService private configurationService: IConfigurationService, @IDialogService private dialogService: IDialogService, @ILogService logService: ILogService, - @ILayoutService layoutService: ILayoutService, - @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private productService: IProductService, @IClipboardService clipboardService: IClipboardService, @INativeHostService private nativeHostService: INativeHostService, @IWorkbenchEnvironmentService private environmentService: IWorkbenchEnvironmentService, - @IOpenerService openerService: IOpenerService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, ) { super(); - this.browserImpl = new Lazy(() => new BrowserDialogHandler(logService, layoutService, keybindingService, instantiationService, clipboardService, openerService, markdownRendererService)); + this.browserImpl = new Lazy(() => instantiationService.createInstance(BrowserDialogHandler)); this.nativeImpl = new Lazy(() => new NativeDialogHandler(logService, nativeHostService, clipboardService)); this.model = (this.dialogService as DialogService).model; diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 9f6ab53f4b30a..3fba2439fa500 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -29,6 +29,7 @@ import { equals } from '../../../../base/common/objects.js'; import { IDefaultChatAgent } from '../../../../base/common/product.js'; import { IRequestContext } from '../../../../base/parts/request/common/request.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; interface IDefaultAccountConfig { readonly preferredExtensions: string[]; @@ -177,6 +178,11 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount return this.defaultAccountProvider?.signIn(options) ?? null; } + async signOut(): Promise { + await this.initBarrier.wait(); + await this.defaultAccountProvider?.signOut(); + } + private setDefaultAccount(account: IDefaultAccount | null): void { if (equals(this.defaultAccount, account)) { return; @@ -244,6 +250,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid @IContextKeyService contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, @IHostService private readonly hostService: IHostService, + @ICommandService private readonly commandService: ICommandService, ) { super(); this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); @@ -822,6 +829,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return this.defaultAccount; } + async signOut(): Promise { + if (!this.defaultAccount) { + return; + } + this.commandService.executeCommand('_signOutOfAccount', { providerId: this.defaultAccount.authenticationProvider.id, accountLabel: this.defaultAccount.accountName }); + } + } class DefaultAccountProviderContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/services/host/browser/browserHostService.ts b/src/vs/workbench/services/host/browser/browserHostService.ts index de8fda44c33eb..6038ad8ac9411 100644 --- a/src/vs/workbench/services/host/browser/browserHostService.ts +++ b/src/vs/workbench/services/host/browser/browserHostService.ts @@ -577,6 +577,10 @@ export class BrowserHostService extends Disposable implements IHostService { // There seems to be no API to bring a window to front in browsers } + async setWindowDimmed(_targetWindow: Window, _dimmed: boolean): Promise { + // not supported in browser + } + async getCursorScreenPoint(): Promise { return undefined; } diff --git a/src/vs/workbench/services/host/browser/host.ts b/src/vs/workbench/services/host/browser/host.ts index 402d97d4634e4..c7d300bf3c7ea 100644 --- a/src/vs/workbench/services/host/browser/host.ts +++ b/src/vs/workbench/services/host/browser/host.ts @@ -105,6 +105,12 @@ export interface IHostService { */ moveTop(targetWindow: Window): Promise; + /** + * Toggle dimming of window control overlays (e.g. when showing + * a modal dialog or modal editor part). + */ + setWindowDimmed(targetWindow: Window, dimmed: boolean): Promise; + /** * Get the location of the mouse cursor and its display bounds or `undefined` if unavailable. */ diff --git a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts index 16defe36affd4..3838e3bb4e530 100644 --- a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +++ b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts @@ -173,6 +173,10 @@ class WorkbenchHostService extends Disposable implements IHostService { return this.nativeHostService.moveWindowTop(isAuxiliaryWindow(targetWindow) ? { targetWindowId: targetWindow.vscodeWindowId } : undefined); } + async setWindowDimmed(targetWindow: Window, dimmed: boolean): Promise { + return this.nativeHostService.updateWindowControls({ dimmed, targetWindowId: getWindowId(targetWindow) }); + } + getCursorScreenPoint(): Promise<{ readonly point: IPoint; readonly display: IRectangle }> { return this.nativeHostService.getCursorScreenPoint(); } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index cb06ab105a116..db6c6bc4aa5ed 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -49,6 +49,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { async signIn(): Promise { return null; } + + async signOut(): Promise { } } suite('AccountPolicyService', () => { diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index eb0e740f798a3..512d8d9111084 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -56,6 +56,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { async signIn(): Promise { return null; } + + async signOut(): Promise { } } suite('MultiplexPolicyService', () => { diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index daf34ce8ee19d..a03c869f95878 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -23,7 +23,8 @@ import { IViewsService } from '../../views/common/viewsService.js'; import { IPaneCompositePartService } from '../../panecomposite/browser/panecomposite.js'; import { stripIcons } from '../../../../base/common/iconLabels.js'; import { IUserActivityService } from '../../userActivity/common/userActivityService.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from '../../../browser/parts/dialogs/dialog.js'; +import { IHostService } from '../../host/browser/host.js'; export class ProgressService extends Disposable implements IProgressService { @@ -39,6 +40,7 @@ export class ProgressService extends Disposable implements IProgressService { @ILayoutService private readonly layoutService: ILayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IUserActivityService private readonly userActivityService: IUserActivityService, + @IHostService private readonly hostService: IHostService, ) { super(); } @@ -567,7 +569,7 @@ export class ProgressService extends Disposable implements IProgressService { cancelId: buttons.length - 1, disableCloseAction: options.sticky, disableDefaultAction: options.sticky - }, this.keybindingService, this.layoutService) + }, this.keybindingService, this.layoutService, this.hostService) ); disposables.add(dialog); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 0684952209a18..a911112cb4ac8 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1379,6 +1379,8 @@ export class TestHostService implements IHostService { async showToast(_options: IToastOptions, token: CancellationToken): Promise { return { supported: false, clicked: false }; } + async setWindowDimmed(_targetWindow: Window, _dimmed: boolean): Promise { } + readonly colorScheme = ColorScheme.DARK; onDidChangeColorScheme = Event.None; }