diff --git a/.github/skills/accessibility.md b/.github/skills/accessibility/SKILL.md similarity index 97% rename from .github/skills/accessibility.md rename to .github/skills/accessibility/SKILL.md index f3177e1e59c85..1d141f0c09275 100644 --- a/.github/skills/accessibility.md +++ b/.github/skills/accessibility/SKILL.md @@ -1,6 +1,6 @@ --- -name: vscode-accessibility -description: Use when creating new UI or updating existing UI features. Accessibility guidelines for VS Code features — covers accessibility help dialogs, accessible views, verbosity settings, accessibility signals, ARIA alerts/status announcements, keyboard navigation, and ARIA labels/roles. Applies to both new interactive UI surfaces and updates to existing features. +name: accessibility +description: Accessibility guidelines for VS Code features — covers accessibility help dialogs, accessible views, verbosity settings, accessibility signals, ARIA alerts/status announcements, keyboard navigation, and ARIA labels/roles. Applies to both new interactive UI surfaces and updates to existing features. Use when creating new UI or updating existing UI features. --- When adding a **new interactive UI surface** to VS Code — a panel, view, widget, editor overlay, dialog, or any rich focusable component the user interacts with — you **must** provide three accessibility components (if they do not already exist for the feature): diff --git a/eslint.config.js b/eslint.config.js index 2a3cec2b0e535..96e1232427b3f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -444,6 +444,7 @@ export default tseslint.config( 'src/vs/platform/log/common/log.ts', 'src/vs/platform/log/common/logIpc.ts', 'src/vs/platform/log/electron-main/logIpc.ts', + 'src/vs/platform/meteredConnection/electron-main/meteredConnectionChannel.ts', 'src/vs/platform/observable/common/wrapInHotClass.ts', 'src/vs/platform/observable/common/wrapInReloadableClass.ts', 'src/vs/platform/policy/common/policyIpc.ts', diff --git a/extensions/git/package.json b/extensions/git/package.json index fd5b557f6ddbc..9772fd7e2b8ce 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -24,6 +24,7 @@ "contribSourceControlTitleMenu", "contribViewsWelcome", "editSessionIdentityProvider", + "envIsConnectionMetered", "findFiles2", "quickDiffProvider", "quickPickSortByLabel", diff --git a/extensions/git/src/autofetch.ts b/extensions/git/src/autofetch.ts index fbd0c821113aa..00d6450b3baf8 100644 --- a/extensions/git/src/autofetch.ts +++ b/extensions/git/src/autofetch.ts @@ -29,6 +29,8 @@ export class AutoFetcher { const onGoodRemoteOperation = filterEvent(repository.onDidRunOperation, ({ operation, error }) => !error && operation.remote); const onFirstGoodRemoteOperation = onceEvent(onGoodRemoteOperation); onFirstGoodRemoteOperation(this.onFirstGoodRemoteOperation, this, this.disposables); + + env.onDidChangeMeteredConnection(() => this.onConfiguration(), this, this.disposables); } private async onFirstGoodRemoteOperation(): Promise { @@ -66,6 +68,11 @@ export class AutoFetcher { return; } + if (env.isMeteredConnection) { + this.disable(); + return; + } + const gitConfig = workspace.getConfiguration('git', Uri.file(this.repository.root)); switch (gitConfig.get('autofetch')) { case true: diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index c9df6ca6f9091..81c71196897dd 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -14,6 +14,7 @@ "../../src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts", "../../src/vscode-dts/vscode.proposed.findFiles2.d.ts", "../../src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts", + "../../src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts", "../../src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts", "../../src/vscode-dts/vscode.proposed.quickPickSortByLabel.d.ts", "../../src/vscode-dts/vscode.proposed.scmActionButton.d.ts", diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index c946703eea8e7..40140f3b6ac3a 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -5,7 +5,7 @@ "type": "dark", "colors": { "foreground": "#bfbfbf", - "disabledForeground": "#444444", + "disabledForeground": "#666666", "errorForeground": "#f48771", "descriptionForeground": "#999999", "icon.foreground": "#888888", diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index fe160d2c76c28..cf2386763bae5 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -19,6 +19,7 @@ "editorInsets", "embeddings", "envIsAppPortable", + "envIsConnectionMetered", "extensionRuntime", "extensionsAny", "externalUriOpener", diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index afd3b1fcf456a..1be43717c32be 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -60,6 +60,10 @@ import { ILifecycleMainService, LifecycleMainPhase, ShutdownReason } from '../.. import { ILoggerService, ILogService } from '../../platform/log/common/log.js'; import { IMenubarMainService, MenubarMainService } from '../../platform/menubar/electron-main/menubarMainService.js'; import { INativeHostMainService, NativeHostMainService } from '../../platform/native/electron-main/nativeHostMainService.js'; +import { IMeteredConnectionService } from '../../platform/meteredConnection/common/meteredConnection.js'; +import { METERED_CONNECTION_CHANNEL } from '../../platform/meteredConnection/common/meteredConnectionIpc.js'; +import { MeteredConnectionChannel } from '../../platform/meteredConnection/electron-main/meteredConnectionChannel.js'; +import { MeteredConnectionMainService } from '../../platform/meteredConnection/electron-main/meteredConnectionMainService.js'; import { IProductService } from '../../platform/product/common/productService.js'; import { getRemoteAuthority } from '../../platform/remote/common/remoteHosts.js'; import { SharedProcess } from '../../platform/sharedProcess/electron-main/sharedProcess.js'; @@ -589,6 +593,11 @@ export class CodeApplication extends Disposable { // Error telemetry appInstantiationService.invokeFunction(accessor => this._register(new ErrorTelemetry(accessor.get(ILogService), accessor.get(ITelemetryService)))); + // Metered connection telemetry + appInstantiationService.invokeFunction(accessor => { + (accessor.get(IMeteredConnectionService) as MeteredConnectionMainService).setTelemetryService(accessor.get(ITelemetryService)); + }); + // Auth Handler appInstantiationService.invokeFunction(accessor => accessor.get(IProxyAuthService)); @@ -1039,6 +1048,10 @@ export class CodeApplication extends Disposable { // Native Host services.set(INativeHostMainService, new SyncDescriptor(NativeHostMainService, undefined, false /* proxied to other processes */)); + // Metered Connection + const meteredConnectionService = new MeteredConnectionMainService(this.configurationService); + services.set(IMeteredConnectionService, meteredConnectionService); + // Web Contents Extractor services.set(IWebContentExtractorService, new SyncDescriptor(NativeWebContentExtractorService, undefined, false /* proxied to other processes */)); @@ -1168,6 +1181,11 @@ export class CodeApplication extends Disposable { const updateChannel = new UpdateChannel(accessor.get(IUpdateService)); mainProcessElectronServer.registerChannel('update', updateChannel); + // Metered Connection + const meteredConnectionChannel = new MeteredConnectionChannel(accessor.get(IMeteredConnectionService) as MeteredConnectionMainService); + mainProcessElectronServer.registerChannel(METERED_CONNECTION_CHANNEL, meteredConnectionChannel); + sharedProcessClient.then(client => client.registerChannel(METERED_CONNECTION_CHANNEL, meteredConnectionChannel)); + // Process const processChannel = ProxyChannel.fromService(new ProcessMainService(this.logService, accessor.get(IDiagnosticsService), accessor.get(IDiagnosticsMainService)), disposables); mainProcessElectronServer.registerChannel('process', processChannel); diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 89d97361485c1..ae5f6def0951d 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -132,6 +132,8 @@ import { McpManagementChannel } from '../../../platform/mcp/common/mcpManagement import { AllowedMcpServersService } from '../../../platform/mcp/common/allowedMcpServersService.js'; import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGalleryManifest.js'; import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; +import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; +import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -295,6 +297,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const nativeHostService = new NativeHostService(-1 /* we are not running in a browser window context */, mainProcessService) as INativeHostService; services.set(INativeHostService, nativeHostService); + // Metered Connection + const meteredConnectionService = this._register(new MeteredConnectionChannelClient(mainProcessService.getChannel(METERED_CONNECTION_CHANNEL))); + services.set(IMeteredConnectionService, meteredConnectionService); + // Download services.set(IDownloadService, new SyncDescriptor(DownloadService, undefined, true)); @@ -321,6 +327,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, this.configuration.machineId, this.configuration.sqmId, this.configuration.devDeviceId, internalTelemetry, productService.date), sendErrorTelemetry: true, piiPaths: getPiiPathsFromEnvironment(environmentService), + meteredConnectionService, }, configurationService, productService); } else { telemetryService = NullTelemetryService; diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.css b/src/vs/platform/actions/browser/menuEntryActionViewItem.css index 7eb35af7e4b00..31095f8938c24 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.css +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.css @@ -43,6 +43,15 @@ background-size: 16px; } +.monaco-dropdown-with-default > .action-container.disabled { + cursor: default; +} + +.monaco-dropdown-with-default > .action-container.disabled > .action-label { + opacity: 0.4; + pointer-events: none; +} + .monaco-dropdown-with-default:hover { background-color: var(--vscode-toolbar-hoverBackground); } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 3c7108b986972..0107bb94b5807 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -233,6 +233,9 @@ const _allApiProposals = { envIsAppPortable: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.envIsAppPortable.d.ts', }, + envIsConnectionMetered: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts', + }, environmentPower: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.environmentPower.d.ts', }, diff --git a/src/vs/platform/menubar/electron-main/menubar.ts b/src/vs/platform/menubar/electron-main/menubar.ts index ad3a31d7e5a12..d8f65c50b4db6 100644 --- a/src/vs/platform/menubar/electron-main/menubar.ts +++ b/src/vs/platform/menubar/electron-main/menubar.ts @@ -643,7 +643,7 @@ export class Menubar extends Disposable { case StateType.AvailableForDownload: return [new MenuItem({ label: this.mnemonicLabel(nls.localize('miDownloadUpdate', "D&&ownload Available Update")), click: () => { - this.updateService.downloadUpdate(); + this.updateService.downloadUpdate(true); } })]; diff --git a/src/vs/platform/meteredConnection/browser/meteredConnectionService.ts b/src/vs/platform/meteredConnection/browser/meteredConnectionService.ts new file mode 100644 index 0000000000000..19f9c84f8a90d --- /dev/null +++ b/src/vs/platform/meteredConnection/browser/meteredConnectionService.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toDisposable } from '../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { AbstractMeteredConnectionService, getIsBrowserConnectionMetered, IMeteredConnectionService, NavigatorWithConnection } from '../common/meteredConnection.js'; + +/** + * Browser implementation of the metered connection service. + * This implementation monitors navigator.connection for changes. + */ +export class MeteredConnectionService extends AbstractMeteredConnectionService { + constructor(@IConfigurationService configurationService: IConfigurationService) { + super(configurationService, getIsBrowserConnectionMetered()); + + const connection = (navigator as NavigatorWithConnection).connection; + if (connection) { + const onChange = () => this.setIsBrowserConnectionMetered(getIsBrowserConnectionMetered()); + connection.addEventListener('change', onChange); + this._register(toDisposable(() => connection.removeEventListener('change', onChange))); + } + } +} + +registerSingleton(IMeteredConnectionService, MeteredConnectionService, InstantiationType.Delayed); diff --git a/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts b/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts new file mode 100644 index 0000000000000..eeff32a25eac5 --- /dev/null +++ b/src/vs/platform/meteredConnection/common/meteredConnection.config.contribution.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { Registry } from '../../registry/common/platform.js'; + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'network', + order: 14, + title: localize('networkConfigurationTitle', "Network"), + type: 'object', + properties: { + 'network.respectMeteredConnections': { + type: 'boolean', + default: true, + scope: ConfigurationScope.APPLICATION, + description: localize('respectMeteredConnections', "When enabled, automatic updates and downloads will be postponed when on a metered network connection (such as mobile data or tethering)."), + tags: ['usesOnlineServices'] + } + } +}); diff --git a/src/vs/platform/meteredConnection/common/meteredConnection.ts b/src/vs/platform/meteredConnection/common/meteredConnection.ts new file mode 100644 index 0000000000000..321aec6c5403c --- /dev/null +++ b/src/vs/platform/meteredConnection/common/meteredConnection.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const IMeteredConnectionService = createDecorator('meteredConnectionService'); + +/** + * Service to report on metered connection status. + */ +export interface IMeteredConnectionService { + readonly _serviceBrand: undefined; + + /** + * Whether the current network connection is metered. + * Always returns `false` if the `network.respectMeteredConnections` setting is disabled. + */ + readonly isConnectionMetered: boolean; + + /** + * Event that fires when the metered connection status changes. + */ + readonly onDidChangeIsConnectionMetered: Event; +} + +export const METERED_CONNECTION_SETTING_KEY = 'network.respectMeteredConnections'; + +/** + * Network Information API + * See https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API + */ +export interface NetworkInformation { + saveData?: boolean; + metered?: boolean; + effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; + addEventListener(type: 'change', listener: () => void): void; + removeEventListener(type: 'change', listener: () => void): void; +} + +/** + * Extended Navigator interface for Network Information API + */ +export interface NavigatorWithConnection { + readonly connection?: NetworkInformation; +} + +/** + * Check if the current network connection is metered according to the Network Information API. + */ +export function getIsBrowserConnectionMetered() { + const connection = (navigator as NavigatorWithConnection).connection; + if (!connection) { + return false; + } + + if (connection.saveData || connection.metered) { + return true; + } + + const effectiveType = connection.effectiveType; + return effectiveType === '2g' || effectiveType === 'slow-2g'; +} + +/** + * Abstract base class for metered connection services. + */ +export abstract class AbstractMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + public readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + private _isConnectionMetered: boolean; + private _isBrowserConnectionMetered: boolean; + private _respectMeteredConnections: boolean; + + constructor(configurationService: IConfigurationService, isBrowserConnectionMetered: boolean) { + super(); + + this._isBrowserConnectionMetered = isBrowserConnectionMetered; + this._respectMeteredConnections = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); + this._isConnectionMetered = this._respectMeteredConnections && this._isBrowserConnectionMetered; + + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(METERED_CONNECTION_SETTING_KEY)) { + const value = configurationService.getValue(METERED_CONNECTION_SETTING_KEY); + if (value !== this._respectMeteredConnections) { + this._respectMeteredConnections = value; + this.onUpdated(); + } + } + })); + } + + public get isConnectionMetered(): boolean { + return this._isConnectionMetered; + } + + protected get isBrowserConnectionMetered(): boolean { + return this._isBrowserConnectionMetered; + } + + public setIsBrowserConnectionMetered(value: boolean) { + if (value !== this._isBrowserConnectionMetered) { + this._isBrowserConnectionMetered = value; + this.onChangeBrowserConnection(); + } + } + + protected onChangeBrowserConnection() { + this.onUpdated(); + } + + protected onUpdated() { + const value = this._respectMeteredConnections && this._isBrowserConnectionMetered; + if (value !== this._isConnectionMetered) { + this._isConnectionMetered = value; + this.onChangeIsConnectionMetered(); + } + } + + protected onChangeIsConnectionMetered() { + this._onDidChangeIsConnectionMetered.fire(this._isConnectionMetered); + } +} diff --git a/src/vs/platform/meteredConnection/common/meteredConnectionIpc.ts b/src/vs/platform/meteredConnection/common/meteredConnectionIpc.ts new file mode 100644 index 0000000000000..5cfbeae576ae8 --- /dev/null +++ b/src/vs/platform/meteredConnection/common/meteredConnectionIpc.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMeteredConnectionService } from './meteredConnection.js'; + +export const METERED_CONNECTION_CHANNEL = 'meteredConnection'; + +/** + * Commands supported by the metered connection IPC channel. + */ +export enum MeteredConnectionCommand { + OnDidChangeIsConnectionMetered = 'OnDidChangeIsConnectionMetered', + IsConnectionMetered = 'IsConnectionMetered', + SetIsBrowserConnectionMetered = 'SetIsBrowserConnectionMetered', +} + +/** + * IPC channel client for the metered connection service. + */ +export class MeteredConnectionChannelClient extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + public readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + private _isConnectionMetered = false; + public get isConnectionMetered(): boolean { + return this._isConnectionMetered; + } + + constructor(channel: IChannel) { + super(); + + channel.call(MeteredConnectionCommand.IsConnectionMetered).then(value => { + this._isConnectionMetered = value; + if (value) { + this._onDidChangeIsConnectionMetered.fire(value); + } + }); + + this._register(channel.listen(MeteredConnectionCommand.OnDidChangeIsConnectionMetered)(value => { + if (this._isConnectionMetered !== value) { + this._isConnectionMetered = value; + this._onDidChangeIsConnectionMetered.fire(value); + } + })); + } +} diff --git a/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts b/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts new file mode 100644 index 0000000000000..cf0276efc2e92 --- /dev/null +++ b/src/vs/platform/meteredConnection/electron-browser/meteredConnectionService.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toDisposable } from '../../../base/common/lifecycle.js'; +import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { AbstractMeteredConnectionService, getIsBrowserConnectionMetered, IMeteredConnectionService, NavigatorWithConnection } from '../common/meteredConnection.js'; +import { METERED_CONNECTION_CHANNEL, MeteredConnectionCommand } from '../common/meteredConnectionIpc.js'; + +/** + * Electron-browser implementation of the metered connection service. + * This implementation monitors navigator.connection and reports changes to the main process via IPC channel. + */ +export class NativeMeteredConnectionService extends AbstractMeteredConnectionService { + private readonly _channel: IChannel; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + @IMainProcessService mainProcessService: IMainProcessService + ) { + super(configurationService, getIsBrowserConnectionMetered()); + this._channel = mainProcessService.getChannel(METERED_CONNECTION_CHANNEL); + + const connection = (navigator as NavigatorWithConnection).connection; + if (connection) { + const onChange = () => this.setIsBrowserConnectionMetered(getIsBrowserConnectionMetered()); + connection.addEventListener('change', onChange); + this._register(toDisposable(() => connection.removeEventListener('change', onChange))); + } + } + + /** + * Notify the main process about changes to the navigator connection state. + */ + protected override onChangeBrowserConnection(): void { + super.onChangeBrowserConnection(); + this._channel.call(MeteredConnectionCommand.SetIsBrowserConnectionMetered, this.isBrowserConnectionMetered); + } +} + +registerSingleton(IMeteredConnectionService, NativeMeteredConnectionService, InstantiationType.Delayed); diff --git a/src/vs/platform/meteredConnection/electron-main/meteredConnectionChannel.ts b/src/vs/platform/meteredConnection/electron-main/meteredConnectionChannel.ts new file mode 100644 index 0000000000000..4246eaad88872 --- /dev/null +++ b/src/vs/platform/meteredConnection/electron-main/meteredConnectionChannel.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { MeteredConnectionCommand } from '../common/meteredConnectionIpc.js'; +import { MeteredConnectionMainService } from './meteredConnectionMainService.js'; + +/** + * IPC channel implementation for the metered connection service. + */ +export class MeteredConnectionChannel implements IServerChannel { + constructor(private readonly service: MeteredConnectionMainService) { } + + public listen(_: unknown, event: any): Event { + switch (event) { + case MeteredConnectionCommand.OnDidChangeIsConnectionMetered: + return this.service.onDidChangeIsConnectionMetered; + default: + throw new Error(`Event not found: ${event}`); + } + } + + public async call(_: unknown, command: string, arg?: any): Promise { + switch (command) { + case MeteredConnectionCommand.IsConnectionMetered: + return this.service.isConnectionMetered; + case MeteredConnectionCommand.SetIsBrowserConnectionMetered: + this.service.setIsBrowserConnectionMetered(arg); + break; + default: + throw new Error(`Call not found: ${command}`); + } + } +} diff --git a/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts b/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts new file mode 100644 index 0000000000000..864e9f09693ae --- /dev/null +++ b/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { AbstractMeteredConnectionService } from '../common/meteredConnection.js'; + +/** + * Electron-main implementation of the metered connection service. + * This implementation receives metered connection updates via IPC channel from the renderer process. + */ +export class MeteredConnectionMainService extends AbstractMeteredConnectionService { + private telemetryService: ITelemetryService | undefined; + + constructor(@IConfigurationService configurationService: IConfigurationService) { + super(configurationService, false); + } + + public setTelemetryService(telemetryService: ITelemetryService): void { + this.telemetryService = telemetryService; + } + + protected override onChangeBrowserConnection() { + // Fire event after sending telemetry if switching to metered since telemetry will be paused. + const fireAfter = this.isBrowserConnectionMetered; + if (!fireAfter) { + super.onChangeBrowserConnection(); + } + + type MeteredConnectionStateChangeEvent = { + connectionState: boolean; + }; + type MeteredConnectionStateChangeClassification = { + owner: 'dmitrivMS'; + comment: 'Tracks metered network connection state changes to understand usage patterns.'; + connectionState: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the underlying network connection is metered according to the OS.' }; + }; + this.telemetryService?.publicLog2('meteredConnectionStateChange', { + connectionState: this.isBrowserConnectionMetered, + }); + + if (fireAfter) { + super.onChangeBrowserConnection(); + } + } +} diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index 346161ce0e3d6..445609fb7d33b 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -11,6 +11,7 @@ import { escapeRegExpCharacters } from '../../../base/common/strings.js'; import { localize } from '../../../nls.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; import { Registry } from '../../registry/common/platform.js'; @@ -28,6 +29,10 @@ export interface ITelemetryServiceConfig { * (up to 10 seconds) to ensure experiment context is attached to all events. */ waitForExperimentProperties?: boolean; + /** + * If provided, telemetry events will be dropped when the connection is metered. + */ + meteredConnectionService?: IMeteredConnectionService; } interface IPendingEvent { @@ -60,6 +65,8 @@ export class TelemetryService implements ITelemetryService { private _telemetryLevel: TelemetryLevel; private _sendErrorTelemetry: boolean; + private readonly _meteredConnectionService: IMeteredConnectionService | undefined; + private _pendingEvents: IPendingEvent[] = []; private _isExperimentPropertySet = false; private _flushTimeout: ReturnType | undefined; @@ -85,6 +92,7 @@ export class TelemetryService implements ITelemetryService { this._piiPaths = config.piiPaths || []; this._telemetryLevel = TelemetryLevel.USAGE; this._sendErrorTelemetry = !!config.sendErrorTelemetry; + this._meteredConnectionService = config.meteredConnectionService; // static cleanup pattern for: `vscode-file:///DANGEROUS/PATH/resources/app/Useful/Information` this._cleanupPatterns = [/(vscode-)?file:\/\/.*?\/resources\/app\//gi]; @@ -180,6 +188,11 @@ export class TelemetryService implements ITelemetryService { return; } + // Don't send events when the connection is metered + if (this._meteredConnectionService?.isConnectionMetered) { + return; + } + // Buffer events until experiment properties are set (or timeout expires) if (!this._isExperimentPropertySet) { if (this._pendingEvents.length < TelemetryService.MAX_BUFFER_SIZE) { diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index e1be2b01e3644..40d896439e298 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -23,14 +23,14 @@ export interface IUpdate { * Idle * ↓ ↑ * Checking for Updates → Available for Download - * ↓ + * ↓ ↓ * ← Overwriting * Downloading ↑ * → Ready * ↓ ↑ * Downloaded → Updating * - * Available: There is an update available for download (linux). + * Available: There is an update available for download (linux, darwin on metered connection). * Ready: Code will be updated as soon as it restarts (win32, darwin). * Downloaded: There is an update ready to be installed in the background (win32). * Overwriting: A newer update is being downloaded to replace the pending update (darwin). @@ -106,7 +106,7 @@ export interface IUpdateService { readonly state: State; checkForUpdates(explicit: boolean): Promise; - downloadUpdate(): Promise; + downloadUpdate(explicit: boolean): Promise; applyUpdate(): Promise; quitAndInstall(): Promise; diff --git a/src/vs/platform/update/common/updateIpc.ts b/src/vs/platform/update/common/updateIpc.ts index 4a2394af0555f..e6675d73f4009 100644 --- a/src/vs/platform/update/common/updateIpc.ts +++ b/src/vs/platform/update/common/updateIpc.ts @@ -23,7 +23,7 @@ export class UpdateChannel implements IServerChannel { call(_: unknown, command: string, arg?: any): Promise { switch (command) { case 'checkForUpdates': return this.service.checkForUpdates(arg); - case 'downloadUpdate': return this.service.downloadUpdate(); + case 'downloadUpdate': return this.service.downloadUpdate(arg); case 'applyUpdate': return this.service.applyUpdate(); case 'quitAndInstall': return this.service.quitAndInstall(); case '_getInitialState': return Promise.resolve(this.service.state); @@ -60,8 +60,8 @@ export class UpdateChannelClient implements IUpdateService { return this.channel.call('checkForUpdates', explicit); } - downloadUpdate(): Promise { - return this.channel.call('downloadUpdate'); + downloadUpdate(explicit: boolean): Promise { + return this.channel.call('downloadUpdate', explicit); } applyUpdate(): Promise { diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index e3bea75cf1fb2..0c396b416812a 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -6,6 +6,7 @@ import { IntervalTimer, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILifecycleMainService, LifecycleMainPhase } from '../../lifecycle/electron-main/lifecycleMainService.js'; @@ -75,6 +76,7 @@ export abstract class AbstractUpdateService implements IUpdateService { @IRequestService protected requestService: IRequestService, @ILogService protected logService: ILogService, @IProductService protected readonly productService: IProductService, + @IMeteredConnectionService protected readonly meteredConnectionService: IMeteredConnectionService, protected readonly supportsUpdateOverwrite: boolean, ) { lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen) @@ -164,13 +166,18 @@ export abstract class AbstractUpdateService implements IUpdateService { this.doCheckForUpdates(explicit); } - async downloadUpdate(): Promise { + async downloadUpdate(explicit: boolean): Promise { this.logService.trace('update#downloadUpdate, state = ', this.state.type); if (this.state.type !== StateType.AvailableForDownload) { return; } + if (!explicit && this.meteredConnectionService.isConnectionMetered) { + this.logService.info('update#downloadUpdate - skipping download because connection is metered'); + return; + } + await this.doDownloadUpdate(this.state); } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index d2d0579bac5be..1433b90ef1a60 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as electron from 'electron'; +import { CancellationToken } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; import { Event } from '../../../base/common/event.js'; import { hash } from '../../../base/common/hash.js'; @@ -13,9 +14,10 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; -import { IRequestService } from '../../request/common/request.js'; +import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { @@ -34,9 +36,10 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau @IEnvironmentMainService environmentMainService: IEnvironmentMainService, @IRequestService requestService: IRequestService, @ILogService logService: ILogService, - @IProductService productService: IProductService + @IProductService productService: IProductService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, true); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -110,9 +113,34 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } + // When connection is metered and this is not an explicit check, avoid electron call as to not to trigger auto-download. + if (!explicit && this.meteredConnectionService.isConnectionMetered) { + this.logService.info('update#doCheckForUpdates - checking for update without auto-download because connection is metered'); + this.checkForUpdateNoDownload(url); + return; + } + electron.autoUpdater.checkForUpdates(); } + /** + * Manually check the update feed URL without triggering Electron's auto-download. + * Used when connection is metered to show update availability without downloading. + */ + private async checkForUpdateNoDownload(url: string): Promise { + try { + const update = await asJson(await this.requestService.request({ url }, CancellationToken.None)); + if (!update || !update.url || !update.version || !update.productVersion) { + this.setState(State.Idle(UpdateType.Archive)); + } else { + this.setState(State.AvailableForDownload(update)); + } + } catch (err) { + this.logService.error(err); + this.setState(State.Idle(UpdateType.Archive)); + } + } + private onUpdateAvailable(): void { if (this.state.type !== StateType.CheckingForUpdates && this.state.type !== StateType.Overwriting) { return; @@ -140,6 +168,13 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.setState(State.Idle(UpdateType.Archive)); } + protected override async doDownloadUpdate(state: AvailableForDownload): Promise { + // Rebuild feed URL and trigger download via Electron's auto-updater + this.buildUpdateFeedUrl(this.quality!, state.update.version); + this.setState(State.CheckingForUpdates(true)); + electron.autoUpdater.checkForUpdates(); + } + protected override doQuitAndInstall(): void { this.logService.trace('update#quitAndInstall(): running raw#quitAndInstall()'); electron.autoUpdater.quitAndInstall(); diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index 32040dca568b3..ee4b291a87ad3 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -8,6 +8,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; @@ -23,9 +24,10 @@ export class LinuxUpdateService extends AbstractUpdateService { @IRequestService requestService: IRequestService, @ILogService logService: ILogService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, - @IProductService productService: IProductService + @IProductService productService: IProductService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); } protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string { diff --git a/src/vs/platform/update/electron-main/updateService.snap.ts b/src/vs/platform/update/electron-main/updateService.snap.ts index 9805d43790b1b..b09111d023506 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -12,6 +12,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { AvailableForDownload, IUpdateService, State, StateType, UpdateType } from '../common/update.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; abstract class AbstractUpdateService implements IUpdateService { @@ -36,6 +37,7 @@ abstract class AbstractUpdateService implements IUpdateService { @ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService, @IEnvironmentMainService environmentMainService: IEnvironmentMainService, @ILogService protected logService: ILogService, + @IMeteredConnectionService protected readonly meteredConnectionService: IMeteredConnectionService, ) { if (environmentMainService.disableUpdates) { this.logService.info('update#ctor - updates are disabled'); @@ -67,13 +69,18 @@ abstract class AbstractUpdateService implements IUpdateService { this.doCheckForUpdates(explicit); } - async downloadUpdate(): Promise { + async downloadUpdate(explicit: boolean): Promise { this.logService.trace('update#downloadUpdate, state = ', this.state.type); if (this.state.type !== StateType.AvailableForDownload) { return; } + if (!explicit && this.meteredConnectionService.isConnectionMetered) { + this.logService.info('update#downloadUpdate - skipping download because connection is metered'); + return; + } + await this.doDownloadUpdate(this.state); } @@ -147,8 +154,9 @@ export class SnapUpdateService extends AbstractUpdateService { @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @IEnvironmentMainService environmentMainService: IEnvironmentMainService, @ILogService logService: ILogService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, environmentMainService, logService); + super(lifecycleMainService, environmentMainService, logService, meteredConnectionService); const watcher = watch(path.dirname(this.snap)); const onChange = Event.fromNodeEventEmitter(watcher, 'change', (_, fileName: string) => fileName); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 72d6af1c48fec..37c7f4e8ec1fe 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -29,6 +29,7 @@ import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; async function pollUntil(fn: () => boolean, millis = 1000): Promise { while (!fn()) { @@ -71,9 +72,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun @ILogService logService: ILogService, @IFileService private readonly fileService: IFileService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, - @IProductService productService: IProductService + @IProductService productService: IProductService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); lifecycleMainService.setRelaunchHandler(this); } @@ -190,6 +192,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } + // When connection is metered and this is not an explicit check, + // show update is available but don't start downloading + if (!explicit && this.meteredConnectionService.isConnectionMetered) { + this.logService.info('update#doCheckForUpdates - update available but skipping download because connection is metered'); + this.setState(State.AvailableForDownload(update)); + return Promise.resolve(null); + } + const startTime = Date.now(); this.setState(State.Downloading(update, explicit, this._overwrite, 0, undefined, startTime)); diff --git a/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts b/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts index 502d5fcf10610..4d60c3b4305fd 100644 --- a/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts @@ -14,6 +14,7 @@ import { isWeb } from '../../../base/common/platform.js'; import { isEqual } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { IProductService } from '../../product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -75,6 +76,7 @@ export class UserDataAutoSyncService extends Disposable implements IUserDataAuto @ITelemetryService private readonly telemetryService: ITelemetryService, @IUserDataSyncMachinesService private readonly userDataSyncMachinesService: IUserDataSyncMachinesService, @IStorageService private readonly storageService: IStorageService, + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, ) { super(); this.syncTriggerDelayer = this._register(new ThrottledDelayer(this.getSyncTriggerDelayTime())); @@ -114,6 +116,7 @@ export class UserDataAutoSyncService extends Disposable implements IUserDataAuto this._register(userDataSyncService.onDidChangeLocal(source => this.triggerSync([source]))); this._register(Event.filter(this.userDataSyncEnablementService.onDidChangeResourceEnablement, ([, enabled]) => enabled)(() => this.triggerSync(['resourceEnablement']))); this._register(this.userDataSyncStoreManagementService.onDidChangeUserDataSyncStore(() => this.triggerSync(['userDataSyncStoreChanged']))); + this._register(meteredConnectionService.onDidChangeIsConnectionMetered(() => this.updateAutoSync())); } } @@ -160,6 +163,9 @@ export class UserDataAutoSyncService extends Disposable implements IUserDataAuto if (this.suspendUntilRestart) { return { enabled: false, message: '[AutoSync] Suspended until restart.' }; } + if (this.meteredConnectionService.isConnectionMetered) { + return { enabled: false, message: '[AutoSync] Suspended because connection is metered.' }; + } return { enabled: true }; } @@ -449,6 +455,7 @@ class AutoSync extends Disposable { private async doSync(reason: string, disableCache: boolean, token: CancellationToken): Promise { this.logService.info(`[AutoSync] Triggered by ${reason}`); + this._onDidStartSync.fire(); let error: Error | undefined; diff --git a/src/vs/platform/userDataSync/node/userDataAutoSyncService.ts b/src/vs/platform/userDataSync/node/userDataAutoSyncService.ts index 821040b4bcfbe..4f7971cc9c386 100644 --- a/src/vs/platform/userDataSync/node/userDataAutoSyncService.ts +++ b/src/vs/platform/userDataSync/node/userDataAutoSyncService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ // import { Event } from '../../../base/common/event.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { INativeHostService } from '../../native/common/native.js'; import { IProductService } from '../../product/common/productService.js'; import { IStorageService } from '../../storage/common/storage.js'; @@ -27,8 +28,9 @@ export class UserDataAutoSyncService extends BaseUserDataAutoSyncService { @ITelemetryService telemetryService: ITelemetryService, @IUserDataSyncMachinesService userDataSyncMachinesService: IUserDataSyncMachinesService, @IStorageService storageService: IStorageService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(productService, userDataSyncStoreManagementService, userDataSyncStoreService, userDataSyncEnablementService, userDataSyncService, logService, authTokenService, telemetryService, userDataSyncMachinesService, storageService); + super(productService, userDataSyncStoreManagementService, userDataSyncStoreService, userDataSyncEnablementService, userDataSyncService, logService, authTokenService, telemetryService, userDataSyncMachinesService, storageService, meteredConnectionService); this._register(Event.debounce(Event.any( Event.map(nativeHostService.onDidFocusMainWindow, () => 'windowFocus'), diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts index 7b749f38b8e1f..e53e777741648 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts @@ -45,6 +45,7 @@ import { InMemoryUserDataProfilesService, IUserDataProfile, IUserDataProfilesSer import { NullPolicyService } from '../../../policy/common/policy.js'; import { IUserDataProfileStorageService } from '../../../userDataProfile/common/userDataProfileStorageService.js'; import { TestUserDataProfileStorageService } from '../../../userDataProfile/test/common/userDataProfileStorageService.test.js'; +import { IMeteredConnectionService } from '../../../meteredConnection/common/meteredConnection.js'; export class UserDataSyncClient extends Disposable { @@ -101,6 +102,8 @@ export class UserDataSyncClient extends Disposable { await configurationService.initialize(); this.instantiationService.stub(IConfigurationService, configurationService); + this.instantiationService.stub(IMeteredConnectionService, { isConnectionMetered: false, onDidChangeIsConnectionMetered: new Emitter().event }); + this.instantiationService.stub(IRequestService, this.testServer); this.instantiationService.stub(IUserDataSyncLogService, logService); diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 6f3d0b866732f..20eab0ab271c1 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -97,6 +97,7 @@ import './mainThreadChatStatus.js'; import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; +import './mainThreadMeteredConnection.js'; import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadMeteredConnection.ts b/src/vs/workbench/api/browser/mainThreadMeteredConnection.ts new file mode 100644 index 0000000000000..5c2fe0ba20ac5 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadMeteredConnection.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, ExtHostMeteredConnectionShape, MainContext, MainThreadMeteredConnectionShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadMeteredConnection) +export class MainThreadMeteredConnection extends Disposable implements MainThreadMeteredConnectionShape { + + private readonly _proxy: ExtHostMeteredConnectionShape; + + constructor( + extHostContext: IExtHostContext, + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService + ) { + super(); + + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostMeteredConnection); + + // Send initial value + this._proxy.$initializeIsConnectionMetered(this.meteredConnectionService.isConnectionMetered); + + // Listen for changes and forward to extension host + this._register(this.meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + this._proxy.$onDidChangeIsConnectionMetered(isMetered); + })); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 0d04a8f0382c3..c58a6d551655a 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -118,6 +118,7 @@ import { IExtHostWindow } from './extHostWindow.js'; import { IExtHostPower } from './extHostPower.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { ExtHostChatContext } from './extHostChatContext.js'; +import { IExtHostMeteredConnection } from './extHostMeteredConnection.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -160,6 +161,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostLanguageModels = accessor.get(IExtHostLanguageModels); const extHostMcp = accessor.get(IExtHostMpcService); const extHostDataChannels = accessor.get(IExtHostDataChannels); + const extHostMeteredConnection = accessor.get(IExtHostMeteredConnection); // register addressable instances rpcProtocol.set(ExtHostContext.ExtHostFileSystemInfo, extHostFileSystemInfo); @@ -181,6 +183,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication); rpcProtocol.set(ExtHostContext.ExtHostChatProvider, extHostLanguageModels); rpcProtocol.set(ExtHostContext.ExtHostDataChannels, extHostDataChannels); + rpcProtocol.set(ExtHostContext.ExtHostMeteredConnection, extHostMeteredConnection); // automatically create and register addressable instances const extHostDecorations = rpcProtocol.set(ExtHostContext.ExtHostDecorations, accessor.get(IExtHostDecorations)); @@ -428,6 +431,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'telemetry'); return _asExtensionEvent(extHostTelemetry.onDidChangeTelemetryConfiguration); }, + get isMeteredConnection(): boolean { + checkProposedApiEnabled(extension, 'envIsConnectionMetered'); + return extHostMeteredConnection.isConnectionMetered; + }, + get onDidChangeMeteredConnection(): vscode.Event { + checkProposedApiEnabled(extension, 'envIsConnectionMetered'); + return _asExtensionEvent(extHostMeteredConnection.onDidChangeIsConnectionMetered); + }, get isNewAppInstall() { return isNewAppInstall(initData.telemetryInfo.firstSessionDate); }, @@ -2011,6 +2022,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseProgressPart: extHostTypes.ChatResponseProgressPart, ChatResponseProgressPart2: extHostTypes.ChatResponseProgressPart2, ChatResponseThinkingProgressPart: extHostTypes.ChatResponseThinkingProgressPart, + ChatResponseHookPart: extHostTypes.ChatResponseHookPart, ChatResponseReferencePart: extHostTypes.ChatResponseReferencePart, ChatResponseReferencePart2: extHostTypes.ChatResponseReferencePart, ChatResponseCodeCitationPart: extHostTypes.ChatResponseCodeCitationPart, diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index d139ccf0c735e..91a91a8ce4837 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -35,6 +35,7 @@ import { ExtHostMcpService, IExtHostMpcService } from './extHostMcp.js'; import { ExtHostUrls, IExtHostUrlsService } from './extHostUrls.js'; import { ExtHostProgress, IExtHostProgress } from './extHostProgress.js'; import { ExtHostDataChannels, IExtHostDataChannels } from './extHostDataChannels.js'; +import { ExtHostMeteredConnection, IExtHostMeteredConnection } from './extHostMeteredConnection.js'; registerSingleton(IExtHostLocalizationService, ExtHostLocalizationService, InstantiationType.Delayed); registerSingleton(ILoggerService, ExtHostLoggerService, InstantiationType.Delayed); @@ -66,3 +67,4 @@ registerSingleton(IExtHostEditorTabs, ExtHostEditorTabs, InstantiationType.Eager registerSingleton(IExtHostVariableResolverProvider, ExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, ExtHostMcpService, InstantiationType.Eager); registerSingleton(IExtHostDataChannels, ExtHostDataChannels, InstantiationType.Eager); +registerSingleton(IExtHostMeteredConnection, ExtHostMeteredConnection, InstantiationType.Eager); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 268fb1c15714d..0d23cba7e36a7 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2602,6 +2602,14 @@ export interface ExtHostTelemetryShape { $onDidChangeTelemetryLevel(level: TelemetryLevel): void; } +export interface MainThreadMeteredConnectionShape extends IDisposable { +} + +export interface ExtHostMeteredConnectionShape { + $initializeIsConnectionMetered(isMetered: boolean): void; + $onDidChangeIsConnectionMetered(isMetered: boolean): void; +} + export interface ITerminalLinkDto { /** The ID of the link to enable activation and disposal. */ id: number; @@ -3487,6 +3495,7 @@ export const MainContext = { MainThreadStorage: createProxyIdentifier('MainThreadStorage'), MainThreadSpeech: createProxyIdentifier('MainThreadSpeechProvider'), MainThreadTelemetry: createProxyIdentifier('MainThreadTelemetry'), + MainThreadMeteredConnection: createProxyIdentifier('MainThreadMeteredConnection'), MainThreadTerminalService: createProxyIdentifier('MainThreadTerminalService'), MainThreadTerminalShellIntegration: createProxyIdentifier('MainThreadTerminalShellIntegration'), MainThreadWebviews: createProxyIdentifier('MainThreadWebviews'), @@ -3602,6 +3611,7 @@ export const ExtHostContext = { ExtHostTimeline: createProxyIdentifier('ExtHostTimeline'), ExtHostTesting: createProxyIdentifier('ExtHostTesting'), ExtHostTelemetry: createProxyIdentifier('ExtHostTelemetry'), + ExtHostMeteredConnection: createProxyIdentifier('ExtHostMeteredConnection'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), ExtHostHooks: createProxyIdentifier('ExtHostHooks'), diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 8872683cc4c27..4df866f871df4 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -206,6 +206,14 @@ export class ChatAgentResponseStream { _report(dto); return this; }, + hookProgress(hookType: vscode.ChatHookType, stopReason?: string, systemMessage?: string) { + throwIfDone(this.hookProgress); + checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); + const part = new extHostTypes.ChatResponseHookPart(hookType, stopReason, systemMessage); + const dto = typeConvert.ChatResponseHookPart.from(part); + _report(dto); + return this; + }, warning(value) { throwIfDone(this.progress); checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); diff --git a/src/vs/workbench/api/common/extHostMeteredConnection.ts b/src/vs/workbench/api/common/extHostMeteredConnection.ts new file mode 100644 index 0000000000000..821a58db7b874 --- /dev/null +++ b/src/vs/workbench/api/common/extHostMeteredConnection.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { ExtHostMeteredConnectionShape } from './extHost.protocol.js'; + +export interface IExtHostMeteredConnection extends ExtHostMeteredConnectionShape { + readonly _serviceBrand: undefined; + readonly isConnectionMetered: boolean; + readonly onDidChangeIsConnectionMetered: Event; +} + +export const IExtHostMeteredConnection = createDecorator('IExtHostMeteredConnection'); + +export class ExtHostMeteredConnection extends Disposable implements IExtHostMeteredConnection, ExtHostMeteredConnectionShape { + + declare readonly _serviceBrand: undefined; + + private _isConnectionMetered: boolean = false; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered: Event = this._onDidChangeIsConnectionMetered.event; + + constructor() { + super(); + } + + get isConnectionMetered(): boolean { + return this._isConnectionMetered; + } + + $initializeIsConnectionMetered(isMetered: boolean): void { + this._isConnectionMetered = isMetered; + } + + $onDidChangeIsConnectionMetered(isMetered: boolean): void { + if (this._isConnectionMetered !== isMetered) { + this._isConnectionMetered = isMetered; + this._onDidChangeIsConnectionMetered.fire(isMetered); + } + } +} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 3ea632f77b186..d85253332ae64 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -41,7 +41,7 @@ import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; @@ -2821,6 +2821,21 @@ export namespace ChatResponseThinkingProgressPart { } } +export namespace ChatResponseHookPart { + export function from(part: vscode.ChatResponseHookPart): Dto { + return { + kind: 'hook', + hookType: part.hookType, + stopReason: part.stopReason, + systemMessage: part.systemMessage, + metadata: part.metadata + }; + } + export function to(part: Dto): vscode.ChatResponseHookPart { + return new types.ChatResponseHookPart(part.hookType, part.stopReason, part.systemMessage, part.metadata); + } +} + export namespace ChatResponseWarningPart { export function from(part: vscode.ChatResponseWarningPart): Dto { return { @@ -3284,6 +3299,8 @@ export namespace ChatResponsePart { return ChatResponseProgressPart.from(part); } else if (part instanceof types.ChatResponseThinkingProgressPart) { return ChatResponseThinkingProgressPart.from(part); + } else if (part instanceof types.ChatResponseHookPart) { + return ChatResponseHookPart.from(part); } else if (part instanceof types.ChatResponseFileTreePart) { return ChatResponseFilesPart.from(part); } else if (part instanceof types.ChatResponseMultiDiffPart) { @@ -3392,6 +3409,7 @@ export namespace ChatAgentRequest { subAgentInvocationId: request.subAgentInvocationId, subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, + hasHooksEnabled: request.hasHooksEnabled ?? false, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3417,6 +3435,8 @@ export namespace ChatAgentRequest { delete (requestWithAllProps as any).subAgentName; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).parentRequestId; + // eslint-disable-next-line local/code-no-any-casts + delete (requestWithAllProps as any).hasHooksEnabled; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index b80dfdf080562..324132be24cc9 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -32,6 +32,7 @@ import { SnippetString } from './extHostTypes/snippetString.js'; import { SymbolKind, SymbolTag } from './extHostTypes/symbolInformation.js'; import { TextEdit } from './extHostTypes/textEdit.js'; import { WorkspaceEdit } from './extHostTypes/workspaceEdit.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export { CodeActionKind } from './extHostTypes/codeActionKind.js'; export { @@ -3231,6 +3232,19 @@ export class ChatResponseThinkingProgressPart { } } +export class ChatResponseHookPart { + hookType: HookTypeValue; + stopReason?: string; + systemMessage?: string; + metadata?: { readonly [key: string]: unknown }; + constructor(hookType: HookTypeValue, stopReason?: string, systemMessage?: string, metadata?: { readonly [key: string]: unknown }) { + this.hookType = hookType; + this.stopReason = stopReason; + this.systemMessage = systemMessage; + this.metadata = metadata; + } +} + export class ChatResponseWarningPart { value: vscode.MarkdownString; constructor(value: string | vscode.MarkdownString) { diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts index 884e8a1316ccc..251305dbb5802 100644 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ b/src/vs/workbench/api/node/extHostHooksNode.ts @@ -9,9 +9,9 @@ import { homedir } from 'os'; import { disposableTimeout } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, isUriComponents } from '../../../base/common/uri.js'; import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; @@ -62,32 +62,44 @@ export class NodeExtHostHooks implements IExtHostHooks { const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; const cwd = cwdUri ? cwdUri.fsPath : home; - // Determine command and args based on which property is specified - // For bash/powershell: spawn the shell directly with explicit args to avoid double shell wrapping - // For generic command: use shell=true to let the system shell handle it - let command: string; - let args: string[]; - let shell: boolean; - if (hook.bash) { - command = 'bash'; - args = ['-c', hook.bash]; - shell = false; - } else if (hook.powershell) { - command = 'powershell'; - args = ['-Command', hook.powershell]; - shell = false; - } else { - command = hook.command!; - args = []; - shell = true; + // Resolve the effective command for the current platform + // This applies windows/linux/osx overrides and falls back to command + const effectiveCommand = resolveEffectiveCommand(hook as Parameters[0]); + if (!effectiveCommand) { + return Promise.resolve({ + kind: HookCommandResultKind.Error, + result: 'No command specified for the current platform' + }); } - const child = spawn(command, args, { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - shell, - }); + // Execute the command, preserving legacy behavior for explicit shell types: + // - powershell source: run through PowerShell so PowerShell-specific commands work + // - bash source: run through bash so bash-specific commands work + // - otherwise: use default shell via spawn with shell: true + const commandSource = getEffectiveCommandSource(hook as Parameters[0]); + let shellExecutable: string | undefined; + let shellArgs: string[] | undefined; + + if (commandSource === 'powershell') { + shellExecutable = 'powershell.exe'; + shellArgs = ['-Command', effectiveCommand]; + } else if (commandSource === 'bash') { + shellExecutable = 'bash'; + shellArgs = ['-c', effectiveCommand]; + } + + const child = shellExecutable && shellArgs + ? spawn(shellExecutable, shellArgs, { + stdio: 'pipe', + cwd, + env: { ...process.env, ...hook.env }, + }) + : spawn(effectiveCommand, [], { + stdio: 'pipe', + cwd, + env: { ...process.env, ...hook.env }, + shell: true, + }); return new Promise((resolve, reject) => { const stdout: string[] = []; @@ -130,7 +142,14 @@ export class NodeExtHostHooks implements IExtHostHooks { // Write input to stdin if (input !== undefined && input !== null) { try { - child.stdin.write(JSON.stringify(input)); + // Use a replacer to convert URI values to filesystem paths. + // URIs arrive as UriComponents objects via the RPC boundary. + child.stdin.write(JSON.stringify(input, (_key, value) => { + if (isUriComponents(value)) { + return URI.revive(value).fsPath; + } + return value; + })); } catch { // Ignore stdin write errors } diff --git a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts index 92fe6a1737718..721f5309ad4ba 100644 --- a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts @@ -474,7 +474,7 @@ export class CustomMenubarControl extends MenubarControl { case StateType.AvailableForDownload: return toAction({ id: 'update.downloadNow', label: localize({ key: 'download now', comment: ['&& denotes a mnemonic'] }, "D&&ownload Update"), enabled: true, run: () => - this.updateService.downloadUpdate() + this.updateService.downloadUpdate(true) }); case StateType.Downloading: diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 445507485ca46..ad793c2770eb4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -51,6 +51,7 @@ export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCa export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); +export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); @@ -105,7 +106,7 @@ class BrowserNavigationBar extends Disposable { // URL input this._urlInput = $('input.browser-url-input'); this._urlInput.type = 'text'; - this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter URL..."); + this._urlInput.placeholder = localize('browser.urlPlaceholder', "Enter a URL"); // Create actions toolbar (right side) with scoped context const actionsContainer = $('.browser-actions-toolbar'); @@ -186,6 +187,7 @@ export class BrowserEditor extends EditorPane { private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; private _storageScopeContext!: IContextKey; + private _hasUrlContext!: IContextKey; private _devToolsOpenContext!: IContextKey; private _elementSelectionActiveContext!: IContextKey; @@ -223,6 +225,7 @@ export class BrowserEditor extends EditorPane { this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); + this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); @@ -766,6 +769,7 @@ export class BrowserEditor extends EditorPane { // Update context keys for command enablement this._canGoBackContext.set(event.canGoBack); this._canGoForwardContext.set(event.canGoForward); + this._hasUrlContext.set(!!event.url); // Update visibility (welcome screen, error, browser view) this.updateVisibility(); @@ -787,15 +791,11 @@ export class BrowserEditor extends EditorPane { content.appendChild(title); const subtitle = $('.browser-welcome-subtitle'); - subtitle.textContent = localize('browser.welcomeSubtitle', "Enter a URL above to get started."); - content.appendChild(subtitle); - const chatEnabled = this.contextKeyService.getContextKeyValue(ChatContextKeys.enabled.key); - if (chatEnabled) { - const tip = $('.browser-welcome-tip'); - tip.textContent = localize('browser.welcomeTip', "Tip: Use Add Element to Chat to reference UI elements in chat prompts."); - content.appendChild(tip); - } + subtitle.textContent = chatEnabled + ? localize('browser.welcomeSubtitleChat', "Use Add Element to Chat to reference UI elements in chat prompts.") + : localize('browser.welcomeSubtitle', "Enter a URL above to get started."); + content.appendChild(subtitle); container.appendChild(content); return container; @@ -919,6 +919,7 @@ export class BrowserEditor extends EditorPane { this._canGoBackContext.reset(); this._canGoForwardContext.reset(); + this._hasUrlContext.reset(); this._storageScopeContext.reset(); this._devToolsOpenContext.reset(); this._elementSelectionActiveContext.reset(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 201d199804bf3..43ce32c896b38 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,7 +11,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; @@ -138,7 +138,6 @@ class GoForwardAction extends Action2 { id: MenuId.BrowserNavigationToolbar, group: 'navigation', order: 2, - when: CONTEXT_BROWSER_CAN_GO_FORWARD }, keybinding: { weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over editor navigation @@ -258,14 +257,14 @@ class ToggleDevToolsAction extends Action2 { id: ToggleDevToolsAction.ID, title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), category: BrowserCategory, - icon: Codicon.console, + icon: Codicon.terminal, f1: true, - precondition: BROWSER_EDITOR_ACTIVE, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupPage, - order: 5, + group: 'actions', + order: 2, }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index c991e57721e9c..0a1ea3099ab1a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -24,6 +24,10 @@ display: flex; align-items: center; flex-shrink: 0; + + .actions-container { + gap: 4px; + } } .browser-url-input { @@ -32,7 +36,7 @@ background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); outline: none; font-size: 13px; @@ -229,15 +233,19 @@ } .browser-welcome-icon { - min-height: 48px; + min-height: 40px; .codicon { font-size: 40px; + margin-bottom: 24px; + color: var(--vscode-descriptionForeground); } } .browser-welcome-title { - font-size: 24px; + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); margin-top: 5px; text-align: center; line-height: normal; @@ -245,12 +253,13 @@ } .browser-welcome-subtitle { + font-size: 12px; position: relative; text-align: center; - max-width: 100%; + max-width: 280px; padding: 0 20px; margin: 8px auto 0; - color: var(--vscode-foreground); + color: var(--vscode-descriptionForeground); p { margin-top: 8px; @@ -258,13 +267,5 @@ } } - .browser-welcome-tip { - color: var(--vscode-descriptionForeground); - text-align: center; - margin: 16px auto 0; - max-width: 400px; - padding: 0 12px; - font-style: italic; - } } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index a733bc3defb51..4a6166dceeac2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -87,6 +87,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age content.push(localize('chat.showHiddenTerminals', 'If there are any hidden chat terminals, you can view them by invoking the View Hidden Chat Terminals command{0}.', '')); content.push(localize('chat.focusMostRecentTerminal', 'To focus the last chat terminal that ran a tool, invoke the Focus Most Recent Chat Terminal command{0}.', ``)); content.push(localize('chat.focusMostRecentTerminalOutput', 'To focus the output from the last chat terminal tool, invoke the Focus Most Recent Chat Terminal Output command{0}.', ``)); + content.push(localize('chat.focusQuestionCarousel', 'When a chat question appears, toggle focus between the question and the chat input{0}.', '')); } if (type === 'editsView' || type === 'agentView') { if (type === 'agentView') { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 764b6543ace7c..2b6400606e763 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -781,6 +781,34 @@ export function registerChatActions() { } }); + registerAction2(class FocusQuestionCarouselAction extends Action2 { + static readonly ID = 'workbench.action.chat.focusQuestionCarousel'; + + constructor() { + super({ + id: FocusQuestionCarouselAction.ID, + title: localize2('interactiveSession.focusQuestionCarousel.label', "Chat: Toggle Focus Between Question and Input"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.inChatSession, + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + when: ChatContextKeys.inChatSession, + }] + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + + if (!widget || !widget.toggleQuestionCarouselFocus()) { + alert(localize('chat.questionCarousel.focusUnavailable', "No chat question to focus right now.")); + } + } + }); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id)); registerAction2(class extends Action2 { constructor() { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 8a363395dc855..466054e33e16c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -649,10 +649,15 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.edits.submit'; constructor() { + const notInProgressOrEditing = ContextKeyExpr.and( + ContextKeyExpr.or(whenNotInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Sent)), + ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.QueueOrSteer) + ); + const menuCondition = ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask); const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, - whenNotInProgress, + notInProgressOrEditing, ChatContextKeys.chatSessionOptionsValid ); @@ -668,7 +673,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { id: MenuId.ChatExecute, order: 4, when: ContextKeyExpr.and( - whenNotInProgress, + notInProgressOrEditing, menuCondition), group: 'navigation', alt: { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index b6627493b7c87..c3200e37a0048 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -21,6 +21,12 @@ import { CHAT_CATEGORY } from './chatActions.js'; const queueingEnabledCondition = ContextKeyExpr.equals(`config.${ChatConfiguration.RequestQueueingEnabled}`, true); const requestInProgressOrPendingToolCall = ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.Editing.hasToolConfirmation); +const queuingActionsPresent = ContextKeyExpr.and( + queueingEnabledCondition, + ContextKeyExpr.or(requestInProgressOrPendingToolCall, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), + ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Sent), +); + export interface IChatRemovePendingRequestContext { sessionResource: URI; pendingRequestId: string; @@ -46,18 +52,17 @@ export class ChatQueueMessageAction extends Action2 { icon: Codicon.add, f1: false, category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( - queueingEnabledCondition, - requestInProgressOrPendingToolCall, - ChatContextKeys.inputHasText + queuingActionsPresent, + ChatContextKeys.inputHasText, ), keybinding: { when: ContextKeyExpr.and( ChatContextKeys.inChatInput, - requestInProgressOrPendingToolCall, - queueingEnabledCondition + queuingActionsPresent, ), - primary: KeyCode.Enter, + primary: KeyMod.Alt | KeyCode.Enter, weight: KeybindingWeight.EditorContrib + 1 }, }); @@ -91,17 +96,15 @@ export class ChatSteerWithMessageAction extends Action2 { f1: false, category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( - queueingEnabledCondition, - requestInProgressOrPendingToolCall, - ChatContextKeys.inputHasText + queuingActionsPresent, + ChatContextKeys.inputHasText, ), keybinding: { when: ContextKeyExpr.and( ChatContextKeys.inChatInput, - requestInProgressOrPendingToolCall, - queueingEnabledCondition + queuingActionsPresent, ), - primary: KeyMod.Alt | KeyCode.Enter, + primary: KeyCode.Enter, weight: KeybindingWeight.EditorContrib + 1 }, }); @@ -287,11 +290,7 @@ export function registerChatQueueActions(): void { submenu: MenuId.ChatExecuteQueue, title: localize2('chat.queueSubmenu', "Queue"), icon: Codicon.listOrdered, - when: ContextKeyExpr.and( - queueingEnabledCondition, - requestInProgressOrPendingToolCall, - ChatContextKeys.inputHasText - ), + when: queuingActionsPresent, group: 'navigation', order: 4, }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 89d29c70882fb..8270198d11069 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -134,7 +134,7 @@ export interface IAgentSessionsControl { export const agentSessionReadIndicatorForeground = registerColor( 'agentSessionReadIndicator.foreground', - { dark: transparent(foreground, 0.15), light: transparent(foreground, 0.15), hcDark: null, hcLight: null }, + { dark: transparent(foreground, 0.2), light: transparent(foreground, 0.2), hcDark: null, hcLight: null }, localize('agentSessionReadIndicatorForeground', "Foreground color for the read indicator in an agent session.") ); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f58192d8d3751..9a030954df416 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -8,7 +8,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; -import { $, append, EventHelper } from '../../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper } from '../../../../../base/browser/dom.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -176,6 +176,14 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo ChatContextKeys.agentSessionsViewerFocused.bindTo(list.contextKeyService); + // Track mouse vs keyboard focus to suppress focus outlines on mouse clicks + this._register(addDisposableListener(this.sessionsContainer!, 'mousedown', () => { + this.sessionsContainer!.classList.add('mouse-focused'); + })); + this._register(addDisposableListener(this.sessionsContainer!, 'keydown', () => { + this.sessionsContainer!.classList.remove('mouse-focused'); + })); + const model = this.agentSessionsService.model; this._register(this.options.filter.onDidChange(async () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index a2c7037a62221..f52968a1ccda4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -56,6 +56,9 @@ interface IAgentSessionItemTemplate { // Column 2 Row 1 readonly title: IconLabel; + readonly statusContainer: HTMLElement; + readonly statusProviderIcon: HTMLElement; + readonly statusTime: HTMLElement; readonly titleToolbar: MenuWorkbenchToolBar; // Column 2 Row 2 @@ -64,12 +67,9 @@ interface IAgentSessionItemTemplate { readonly diffRemovedSpan: HTMLSpanElement; readonly badge: HTMLElement; + readonly separator: HTMLElement; readonly description: HTMLElement; - readonly statusContainer: HTMLElement; - readonly statusProviderIcon: HTMLElement; - readonly statusTime: HTMLElement; - readonly contextKeyService: IContextKeyService; readonly elementDisposable: DisposableStore; readonly disposables: IDisposable; @@ -111,6 +111,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('div.agent-session-main-col', [ h('div.agent-session-title-row', [ h('div.agent-session-title@title'), + h('div.agent-session-status@statusContainer', [ + h('span.agent-session-status-provider-icon@statusProviderIcon'), + h('span.agent-session-status-time@statusTime') + ]), h('div.agent-session-title-toolbar@titleToolbar'), ]), h('div.agent-session-details-row', [ @@ -120,11 +124,8 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('span.agent-session-diff-removed@removedSpan') ]), h('div.agent-session-badge@badge'), + h('span.agent-session-separator@separator'), h('div.agent-session-description@description'), - h('div.agent-session-status@statusContainer', [ - h('span.agent-session-status-provider-icon@statusProviderIcon'), - h('span.agent-session-status-time@statusTime') - ]) ]) ]) ] @@ -147,6 +148,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre diffAddedSpan: elements.addedSpan, diffRemovedSpan: elements.removedSpan, badge: elements.badge, + separator: elements.separator, description: elements.description, statusContainer: elements.statusContainer, statusProviderIcon: elements.statusProviderIcon, @@ -216,6 +218,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre this.renderDescription(session, template, hasBadge); } + // Separator (dot between badge and description) + template.separator.classList.toggle('has-separator', hasBadge && !hasDiff); + // Status this.renderStatus(session, template); @@ -307,8 +312,8 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre const duration = this.toDuration(session.element.timing.lastRequestStarted, session.element.timing.lastRequestEnded, false, true); template.description.textContent = session.element.status === AgentSessionStatus.Failed ? - localize('chat.session.status.failedAfter', "Failed after {0}.", duration) : - localize('chat.session.status.completedAfter', "Completed in {0}.", duration); + localize('chat.session.status.failedAfter', "Failed after {0}", duration) : + localize('chat.session.status.completedAfter', "Completed in {0}", duration); } else { template.description.textContent = session.element.status === AgentSessionStatus.Failed ? localize('chat.session.status.failed', "Failed") : @@ -497,7 +502,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { - static readonly ITEM_HEIGHT = 52; + static readonly ITEM_HEIGHT = 40; static readonly SECTION_HEIGHT = 26; getHeight(element: AgentSessionListItem): number { @@ -708,10 +713,10 @@ export class AgentSessionsDataSource implements IAsyncDataSource= startOfYesterday) { - return localize('date.fromNow.days.singular.ago', '1 day ago'); + return localize('date.fromNow.days.singular', '1 day'); } if (sessionTime < startOfYesterday && sessionTime >= startOfTwoDaysAgo) { - return localize('date.fromNow.days.multiple.ago', '2 days ago'); + return localize('date.fromNow.days.multiple', '2 days'); } - return fromNow(sessionTime, true); + return fromNow(sessionTime, false); } export class AgentSessionsIdentityProvider implements IIdentityProvider { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index a4a142f69e71c..48bb4ade859fb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -9,6 +9,20 @@ height: 100%; min-height: 0; + .monaco-scrollable-element { + padding: 0 6px; + } + + .monaco-list-row { + border-radius: 6px; + } + + /* Hide focus outlines on mouse click, preserve for keyboard navigation */ + &.mouse-focused .monaco-list-row.focused, + &.mouse-focused .monaco-list-row.focused.selected { + outline: none !important; + } + .monaco-list-row .force-no-twistie { display: none !important; } @@ -21,55 +35,60 @@ color: unset; } } + } + .monaco-list:focus .monaco-list-row.selected .agent-session-details-row { .agent-session-diff-container { - background-color: unset; - outline: 1px solid var(--vscode-agentSessionSelectedBadge-border); - .agent-session-diff-added, .agent-session-diff-removed { - color: unset; + color: unset; } } + } - .agent-session-badge { - background-color: unset; - outline: 1px solid var(--vscode-agentSessionSelectedBadge-border); - } + .monaco-list-row.selected .agent-session-title { + color: unset; } - .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-details-row .agent-session-diff-container, - .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-details-row .agent-session-badge { - outline: 1px solid var(--vscode-agentSessionSelectedUnfocusedBadge-border); + .monaco-list-row.selected .agent-session-status { + color: unset; + } + + .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-details-row { + color: var(--vscode-descriptionForeground); } .monaco-list-row .agent-session-title-toolbar { /* for the absolute positioning of the toolbar below */ position: relative; height: 16px; + display: none; .monaco-toolbar { /* this is required because the overal height (including the padding needed for hover feedback) would push down the title otherwise */ position: relative; right: 0; top: 0; - display: none; } } - .monaco-list-row:hover .agent-session-title-toolbar, - .monaco-list-row.focused .agent-session-title-toolbar { + /* On hover or keyboard focus: show toolbar, hide status */ + .monaco-list-row:hover, + &:not(.mouse-focused) .monaco-list-row.focused { - .monaco-toolbar { + .agent-session-title-toolbar { display: block; } + + .agent-session-status { + display: none; + } } .agent-session-item { display: flex; flex-direction: row; - /* to offset from possible scrollbar */ - padding: 8px 12px 8px 8px; + padding: 4px 6px; &.archived { color: var(--vscode-descriptionForeground); @@ -85,10 +104,16 @@ .agent-session-icon-col { display: flex; align-items: flex-start; + line-height: 16px; .agent-session-icon { flex-shrink: 0; - font-size: 16px; + font-size: 12px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; &.codicon.codicon-session-in-progress { color: var(--vscode-textLink-foreground); @@ -113,24 +138,26 @@ } .agent-session-main-col { - padding-left: 8px; + padding-left: 6px; } .agent-session-title-row, .agent-session-details-row { display: flex; align-items: center; - line-height: 16px; } .agent-session-title-row { - padding-bottom: 4px; + line-height: 16px; + padding-bottom: 2px; } .agent-session-details-row { gap: 4px; - font-size: 12px; - color: var(--vscode-descriptionForeground); + font-size: 11px; + line-height: 14px; + max-height: 14px; + color: var(--vscode-disabledForeground); .rendered-markdown { p { @@ -150,11 +177,8 @@ .agent-session-diff-container, .agent-session-badge { - background-color: var(--vscode-toolbar-hoverBackground); font-weight: 500; - padding: 0 4px; font-variant-numeric: tabular-nums; - border-radius: 5px; overflow: hidden; } @@ -175,6 +199,18 @@ } } + .agent-session-separator { + display: none; + + &.has-separator { + display: inline; + + &::before { + content: '\00B7'; + } + } + } + .agent-session-badge { p { @@ -186,7 +222,7 @@ } .codicon { - font-size: 12px; + font-size: 11px; } } } @@ -195,17 +231,28 @@ .agent-session-description { /* push other items to the end */ flex: 1; - text-overflow: ellipsis; overflow: hidden; + white-space: nowrap; + margin-right: 16px; + mask-image: linear-gradient(to right, black calc(100% - 32px), transparent); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 32px), transparent); + } + + .agent-session-title { + font-size: 12px; + color: var(--vscode-descriptionForeground); } .agent-session-status { display: flex; align-items: center; font-variant-numeric: tabular-nums; + font-size: 11px; + color: var(--vscode-disabledForeground); + white-space: nowrap; .agent-session-status-provider-icon { - font-size: 12px; + font-size: 11px; margin-right: 4px; &.hidden { @@ -219,11 +266,10 @@ display: flex; align-items: center; font-size: 11px; + font-weight: 500; color: var(--vscode-descriptionForeground); - text-transform: uppercase; - letter-spacing: 0.5px; /* align with session item padding */ - padding: 0 12px 0 8px; + padding: 0 6px; .agent-session-section-label { flex: 1; diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 3ba2c1aeb53a0..2d2b307f23e84 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -376,6 +376,16 @@ export interface IChatWidget { * @returns Whether the operation succeeded (i.e., the focus was toggled). */ toggleTodosViewFocus(): boolean; + /** + * Focuses the question carousel in the chat widget. + * @returns Whether the operation succeeded (i.e., the question carousel was focused). + */ + focusQuestionCarousel(): boolean; + /** + * Toggles focus between the question carousel and the chat input. + * @returns Whether the operation succeeded (i.e., the focus was toggled). + */ + toggleQuestionCarouselFocus(): boolean; hasInputFocus(): boolean; getModeRequestOptions(): Partial; getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 3604a84f02699..94be213b21e72 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -16,7 +16,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { NEW_HOOK_COMMAND_ID } from './newPromptFileActions.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -147,11 +147,8 @@ class ManageHooksAction extends Action2 { const entry = selected.hookEntry; let selection: ITextEditorSelection | undefined; - // Determine the command field name to highlight - const commandFieldName = entry.command.command !== undefined ? 'command' - : entry.command.bash !== undefined ? 'bash' - : entry.command.powershell !== undefined ? 'powershell' - : undefined; + // Determine the command field name to highlight based on current platform + const commandFieldName = getEffectiveCommandFieldKey(entry.command); // Try to find the command field to highlight if (commandFieldName) { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 1d19f566d1694..6a1e8d11054ef 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -352,7 +352,7 @@ class NewHookFileAction extends Action2 { const hookFileUri = URI.joinPath(selectedFolder.uri, HOOKS_FILENAME); // Check if hooks.json already exists - let hooksContent: { version: number; hooks: Record }; + let hooksContent: { hooks: Record }; const fileExists = await fileService.exists(hookFileUri); if (fileExists) { @@ -373,7 +373,7 @@ class NewHookFileAction extends Action2 { } } else { // Create new structure - hooksContent = { version: 1, hooks: {} }; + hooksContent = { hooks: {} }; } // Add the new hook entry (append if hook type already exists) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 0be7666e97804..1a0e7e62596fb 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -23,6 +23,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -122,6 +123,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, + @ICommandService private readonly _commandService: ICommandService, ) { super(); @@ -422,6 +424,38 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return { hookResult }; } + /** + * Validate updatedInput from a preToolUse hook against the tool's input schema + * using the json.validate command from the JSON extension. + * @returns An error message string if validation fails, or undefined if valid. + */ + private async _validateUpdatedInput(toolId: string, toolData: IToolData | undefined, updatedInput: object): Promise { + if (!toolData?.inputSchema) { + return undefined; + } + + type JsonDiagnostic = { + message: string; + range: { line: number; character: number }[]; + severity: string; + code?: string | number; + }; + + try { + const schemaUri = createToolSchemaUri(toolId); + const inputJson = JSON.stringify(updatedInput); + const diagnostics = await this._commandService.executeCommand('json.validate', schemaUri, inputJson) || []; + if (diagnostics.length > 0) { + return diagnostics.map(d => d.message).join('; '); + } + } catch (e) { + // json extension may not be available; skip validation + this._logService.debug(`[LanguageModelToolsService#_validateUpdatedInput] json.validate command failed, skipping validation: ${toErrorMessage(e)}`); + } + + return undefined; + } + /** * Execute the postToolUse hook after tool completion. * If the hook returns a "block" decision, additional context is appended to the tool result @@ -517,6 +551,17 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return hookDenialResult; } + // Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema + if (preToolUseHookResult?.updatedInput) { + const validationError = await this._validateUpdatedInput(dto.toolId, toolData, preToolUseHookResult.updatedInput); + if (validationError) { + this._logService.warn(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} updatedInput from preToolUse hook failed schema validation: ${validationError}`); + } else { + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} input modified by preToolUse hook`); + dto.parameters = preToolUseHookResult.updatedInput; + } + } + // Fire the event to notify listeners that a tool is being invoked this._onDidInvokeTool.fire({ toolId: dto.toolId, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts new file mode 100644 index 0000000000000..464f07913836b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { localize } from '../../../../../../nls.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IChatHookPart } from '../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; +import { ChatTreeItem } from '../../chat.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import './media/chatHookContentPart.css'; + +function getHookTypeLabel(hookType: HookTypeValue): string { + return HOOK_TYPES.find(hook => hook.id === hookType)?.label ?? hookType; +} + +export class ChatHookContentPart extends ChatCollapsibleContentPart implements IChatContentPart { + + constructor( + private readonly hookPart: IChatHookPart, + context: IChatContentPartRenderContext, + @IHoverService hoverService: IHoverService, + ) { + const hookTypeLabel = getHookTypeLabel(hookPart.hookType); + const isStopped = !!hookPart.stopReason; + const isWarning = !!hookPart.systemMessage; + const title = isStopped + ? localize('hook.title.stopped', "Blocked by {0} hook", hookTypeLabel) + : localize('hook.title.warning', "Warning from {0} hook", hookTypeLabel); + + super(title, context, undefined, hoverService); + + this.icon = isStopped ? Codicon.circleSlash : isWarning ? Codicon.warning : Codicon.check; + + if (isStopped) { + this.domNode.classList.add('chat-hook-outcome-blocked'); + } + + this.setExpanded(false); + } + + protected override initContent(): HTMLElement { + const content = $('.chat-hook-details.chat-used-context-list'); + + if (this.hookPart.stopReason) { + const reasonElement = $('.chat-hook-reason', undefined, this.hookPart.stopReason); + content.appendChild(reasonElement); + } else if (this.hookPart.systemMessage) { + const messageElement = $('.chat-hook-message', undefined, this.hookPart.systemMessage); + content.appendChild(messageElement); + } + + return content; + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + if (other.kind !== 'hook') { + return false; + } + return other.hookType === this.hookPart.hookType && + other.stopReason === this.hookPart.stopReason && + other.systemMessage === this.hookPart.systemMessage; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index d5e8cb4410267..6a0b310b92555 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -10,6 +10,7 @@ import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; +import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -66,11 +67,18 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent context: IChatContentPartRenderContext, private readonly _options: IChatQuestionCarouselOptions, @IHoverService private readonly _hoverService: IHoverService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { super(); this.domNode = dom.$('.chat-question-carousel-container'); + // Set up accessibility attributes for the carousel container + this.domNode.tabIndex = 0; + this.domNode.setAttribute('role', 'region'); + this.domNode.setAttribute('aria-roledescription', localize('chat.questionCarousel.roleDescription', 'chat question')); + this._updateAriaLabel(); + // Restore answers from carousel data if already submitted (e.g., after re-render due to virtualization) if (carousel.data) { for (const [key, value] of Object.entries(carousel.data)) { @@ -195,7 +203,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (newIndex >= 0 && newIndex < this.carousel.questions.length) { this.saveCurrentAnswer(); this._currentIndex = newIndex; - this.renderCurrentQuestion(); + this.renderCurrentQuestion(true); } } @@ -209,7 +217,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (this._currentIndex < this.carousel.questions.length - 1) { // Move to next question this._currentIndex++; - this.renderCurrentQuestion(); + this.renderCurrentQuestion(true); } else { // Submit this._options.onSubmit(this._answers); @@ -217,6 +225,23 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + /** + * Focuses the container element and announces the question for screen reader users. + */ + private _focusContainerAndAnnounce(): void { + this.domNode.focus(); + const question = this.carousel.questions[this._currentIndex]; + if (question) { + const questionText = question.message ?? question.title; + const messageContent = typeof questionText === 'string' ? questionText : questionText.value; + const questionCount = this.carousel.questions.length; + const alertMessage = questionCount === 1 + ? messageContent + : localize('chat.questionCarousel.questionAlertMulti', 'Question {0} of {1}: {2}', this._currentIndex + 1, questionCount, messageContent); + this._accessibilityService.alert(alertMessage); + } + } + /** * Hides the carousel UI and shows a summary of answers. */ @@ -352,7 +377,54 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } - private renderCurrentQuestion(): void { + /** + * Returns whether auto-focus should be enabled. + * Disabled when screen reader mode is active or when explicitly disabled via options. + */ + private _shouldAutoFocus(): boolean { + if (this._options.shouldAutoFocus === false) { + return false; + } + // Disable auto-focus for screen reader users to allow them to read the question first + return !this._accessibilityService.isScreenReaderOptimized(); + } + + /** + * Updates the aria-label of the carousel container based on the current question. + */ + private _updateAriaLabel(): void { + const question = this.carousel.questions[this._currentIndex]; + if (!question) { + this.domNode.setAttribute('aria-label', localize('chat.questionCarousel.label', 'Chat question')); + return; + } + + const questionText = question.message ?? question.title; + const messageContent = typeof questionText === 'string' ? questionText : questionText.value; + const questionCount = this.carousel.questions.length; + + if (questionCount === 1) { + this.domNode.setAttribute('aria-label', localize('chat.questionCarousel.singleQuestionLabel', 'Chat question: {0}', messageContent)); + } else { + this.domNode.setAttribute('aria-label', localize('chat.questionCarousel.multiQuestionLabel', 'Chat question {0} of {1}: {2}', this._currentIndex + 1, questionCount, messageContent)); + } + } + + /** + * Focuses the carousel container element. + */ + public focus(): void { + this.domNode.focus(); + } + + /** + * Returns whether the carousel container has focus. + */ + public hasFocus(): boolean { + return dom.isAncestorOfActiveElement(this.domNode); + } + + private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void { if (!this._questionContainer || !this._prevButton || !this._nextButton) { return; } @@ -445,6 +517,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabel }); } + // Update aria-label to reflect the current question + this._updateAriaLabel(); + + // In screen reader mode, focus the container and announce the question + // This must happen after all render calls to avoid focus being stolen + if (focusContainerForScreenReader && this._accessibilityService.isScreenReaderOptimized()) { + this._focusContainerAndAnnounce(); + } + this._onDidChangeHeight.fire(); } @@ -493,7 +574,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._textInputBoxes.set(question.id, inputBox); // Focus on input when rendered using proper DOM scheduling - if (this._options.shouldAutoFocus !== false) { + if (this._shouldAutoFocus()) { this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(inputBox.element), () => inputBox.focus())); } } @@ -696,7 +777,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } // focus on the row when first rendered or textarea if it has content - if (this._options.shouldAutoFocus !== false) { + if (this._shouldAutoFocus()) { if (previousFreeform) { this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { freeformTextarea.focus(); @@ -891,7 +972,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } // Focus on the appropriate row when rendered or textarea if it has content - if (this._options.shouldAutoFocus !== false) { + if (this._shouldAutoFocus()) { if (previousFreeform) { this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { freeformTextarea.focus(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css new file mode 100644 index 0000000000000..c06e49192d58c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-used-context.chat-hook-outcome-blocked .chat-used-context-label .codicon { + color: var(--vscode-notificationsWarningIcon-foreground) !important; +} + +.chat-hook-details { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; +} + +.chat-hook-message, .chat-hook-reason { + font-size: var(--vscode-chat-font-size-body-s); + padding: 4px 10px; + color: var(--vscode-descriptionForeground); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 00a7702cb3166..38beb93405c2c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -58,7 +58,7 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; @@ -107,6 +107,7 @@ import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool import { isEqual } from '../../../../../base/common/resources.js'; import { IChatTipService } from '../chatTipService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; const $ = dom.$; @@ -979,7 +980,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || - lastPart.kind === 'mcpServersStarting' + lastPart.kind === 'mcpServersStarting' || + lastPart.kind === 'hook' ) { return true; } @@ -1668,6 +1670,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === 'hook' && other.hookType === hookPart.hookType); + } + private renderPullRequestContent(pullRequestContent: IChatPullRequestContent, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): IChatContentPart | undefined { const part = this.instantiationService.createInstance(ChatPullRequestContentPart, pullRequestContent); return part; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 39995958c1ea0..5648329edf155 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -719,6 +719,27 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.input.focusTodoList(); } + focusQuestionCarousel(): boolean { + if (!this.input.questionCarousel) { + return false; + } + + return this.input.focusQuestionCarousel(); + } + + toggleQuestionCarouselFocus(): boolean { + if (!this.input.questionCarousel) { + return false; + } + + if (this.input.isQuestionCarouselFocused()) { + this.focusInput(); + return true; + } + + return this.input.focusQuestionCarousel(); + } + hasInputFocus(): boolean { return this.input.hasFocus(); } @@ -1517,9 +1538,9 @@ export class ChatWidget extends Disposable implements IChatWidget { if (request.id === currentElement.id) { request.setShouldBeBlocked(false); // unblocking just this request. request.attachedContext?.forEach(addToContext); - currentElement.variables.forEach(addToContext); } } + currentElement.variables.forEach(addToContext); // set states this.viewModel?.setEditing(currentElement); @@ -1527,8 +1548,9 @@ export class ChatWidget extends Disposable implements IChatWidget { ChatContextKeys.currentlyEditing.bindTo(item.contextKeyService).set(true); } + const isEditingSentRequest = currentElement.pendingKind === undefined ? ChatContextKeys.EditingRequestType.Sent : ChatContextKeys.EditingRequestType.QueueOrSteer; const isInput = this.configurationService.getValue('chat.editRequests') === 'input'; - this.inputPart?.setEditing(!!this.viewModel?.editing && isInput); + this.inputPart?.setEditing(!!this.viewModel?.editing && isInput, isEditingSentRequest); if (!isInput) { const rowContainer = item.rowContainer; @@ -1536,6 +1558,7 @@ export class ChatWidget extends Disposable implements IChatWidget { rowContainer.appendChild(this.inputContainer); this.createInput(this.inputContainer); this.input.setChatMode(this.inputPart.currentModeObs.get().id); + this.input.setEditing(true, isEditingSentRequest); } else { this.inputPart.element.classList.add('editing'); } @@ -1545,7 +1568,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.input.attachmentModel.addContext(...currentContext); } - // rerenders this.inputPart.dnd.setDisabledOverlay(!isInput); this.input.renderAttachedContext(); @@ -1628,8 +1650,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.element.classList.remove('editing'); } this.viewModel?.setEditing(undefined); - - this.inputPart?.setEditing(!!this.viewModel?.editing && isInput); + this.inputPart?.setEditing(false, undefined); this.onDidChangeItems(); @@ -2117,13 +2138,16 @@ export class ChatWidget extends Disposable implements IChatWidget { }; const isUserQuery = !query; - - if (this.viewModel?.editing) { - const editingPendingRequest = this.viewModel.editing.pendingKind; + const isEditing = this.viewModel?.editing; + if (isEditing) { + const editingPendingRequest = this.viewModel.editing!.pendingKind; if (editingPendingRequest !== undefined) { const editingRequestId = this.viewModel.editing!.id; this.chatService.removePendingRequest(this.viewModel.sessionResource, editingRequestId); options.queue ??= editingPendingRequest; + } else { + this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); + options.queue = undefined; } this.finishedEditing(true); @@ -2135,7 +2159,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (requestInProgress) { options.queue ??= ChatRequestQueueKind.Queued; } - if (!requestInProgress && !(await this.confirmPendingRequestsBeforeSend(model, options))) { + if (!requestInProgress && !isEditing && !(await this.confirmPendingRequestsBeforeSend(model, options))) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index dd00cc4c58988..ff765e49c3ca4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -333,6 +333,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatCursorAtTop: IContextKey; private inputEditorHasFocus: IContextKey; private currentlyEditingInputKey!: IContextKey; + private editingSentRequestKey!: IContextKey; private chatModeKindKey: IContextKey; private chatModeNameKey: IContextKey; private withinEditSessionKey: IContextKey; @@ -717,8 +718,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); } - public setEditing(enabled: boolean) { + public setEditing(enabled: boolean, editingSentRequest: ChatContextKeys.EditingRequestType | undefined) { this.currentlyEditingInputKey?.set(enabled); + this.editingSentRequestKey?.set(editingSentRequest); } public switchModel(modelMetadata: Pick) { @@ -1843,6 +1845,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(inputContainer)); ChatContextKeys.inChatInput.bindTo(inputScopedContextKeyService).set(true); this.currentlyEditingInputKey = ChatContextKeys.currentlyEditingInput.bindTo(inputScopedContextKeyService); + this.editingSentRequestKey = ChatContextKeys.editingRequestType.bindTo(this.contextKeyService); const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); @@ -2452,6 +2455,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._chatQuestionCarouselWidget.value; } + focusQuestionCarousel(): boolean { + const carousel = this._chatQuestionCarouselWidget.value; + if (carousel) { + carousel.focus(); + return true; + } + return false; + } + + isQuestionCarouselFocused(): boolean { + const carousel = this._chatQuestionCarouselWidget.value; + return carousel?.hasFocus() ?? false; + } + setWorkingSetCollapsed(collapsed: boolean): void { this._workingSetCollapsed.set(collapsed, undefined); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index 38809b39a137e..5d5b59ccca8fd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -20,10 +20,10 @@ import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWi import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ChatSubmitAction } from '../../actions/chatExecuteActions.js'; import { ChatQueueMessageAction, ChatSteerWithMessageAction } from '../../actions/chatQueueActions.js'; @@ -61,12 +61,16 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._primaryActionAction = this._register(new Action( 'chat.queuePickerPrimary', isSteerDefault ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"), - ThemeIcon.asClassName(isSteerDefault ? Codicon.arrowRight : Codicon.add), - true, + ThemeIcon.asClassName(Codicon.send), + !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key), () => this._runDefaultAction() )); this._primaryAction = this._register(new ActionViewItem(undefined, this._primaryActionAction, { icon: true, label: false })); + this._register(contextKeyService.onDidChangeContext(e => { + this._primaryActionAction.enabled = !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key); + })); + // Dropdown - action widget with hover descriptions and chevron-down icon const dropdownAction = this._register(new Action('chat.queuePickerDropdown', localize('chat.queuePicker.moreActions', "More Actions..."))); this._dropdown = this._register(new ChevronActionWidgetDropdown( @@ -98,7 +102,6 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._primaryActionAction.label = isSteerDefault ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"); - this._primaryActionAction.class = ThemeIcon.asClassName(isSteerDefault ? Codicon.arrowRight : Codicon.add); } private _runDefaultAction(): void { @@ -188,7 +191,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { const sendAction: IActionWidgetDropdownAction = { id: '_' + ChatSubmitAction.ID, // _ to avoid showing a keybinding which is not valid in this context - label: localize('chat.sendImmediately', "Send Immediately"), + label: localize('chat.sendImmediately', "Stop and Send"), tooltip: '', enabled: true, icon: Codicon.send, @@ -228,10 +231,9 @@ export class ChatQueuePickerRendering extends Disposable implements IWorkbenchCo constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._register(actionViewItemService.register(MenuId.ChatExecute, MenuId.ChatExecuteQueue, (action, options) => { + this._register(actionViewItemService.register(MenuId.ChatExecute, MenuId.ChatExecuteQueue, (action, options, instantiationService) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index 7884cf2c51270..791420b844326 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -100,7 +100,7 @@ } .agent-sessions-new-button-container { - padding: 8px 12px; + padding: 8px; } } @@ -127,7 +127,7 @@ } .agent-session-section { - padding: 0 12px 0 20px; + padding: 0 12px; } /* Right position: symmetric padding */ diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 07dec080b330b..a9b4206c5e549 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -21,6 +21,12 @@ export namespace ChatContextKeys { export const currentlyEditing = new RawContextKey('chatSessionCurrentlyEditing', false, { type: 'boolean', description: localize('interactiveSessionCurrentlyEditing', "True when the current request is being edited.") }); export const currentlyEditingInput = new RawContextKey('chatSessionCurrentlyEditingInput', false, { type: 'boolean', description: localize('interactiveSessionCurrentlyEditingInput', "True when the current request input at the bottom is being edited.") }); + export const enum EditingRequestType { + Sent = 's', + QueueOrSteer = 'qs', + } + export const editingRequestType = new RawContextKey('chatEditingSentRequest', undefined, { type: 'string', description: localize('chatEditingSentRequest', "The type of the current editing request.") }); + export const isResponse = new RawContextKey('chatResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") }); export const isRequest = new RawContextKey('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") }); export const isPendingRequest = new RawContextKey('chatRequestIsPending', false, { type: 'boolean', description: localize('chatRequestIsPending', "True when the chat request item is pending in the queue.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 4c4a4b6ec7918..c56b97b691858 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -14,6 +14,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; +import { HookTypeValue } from '../promptSyntax/hookSchema.js'; import { ISelection } from '../../../../../editor/common/core/selection.js'; import { Command, Location, TextEdit } from '../../../../../editor/common/languages.js'; import { FileType } from '../../../../../platform/files/common/files.js'; @@ -420,6 +421,22 @@ export interface IChatThinkingPart { generatedTitle?: string; } +/** + * A progress part representing the execution result of a hook. + * Aligned with the hook output JSON structure: { stopReason, systemMessage, hookSpecificOutput }. + * If {@link stopReason} is set, the hook blocked/denied the operation. + */ +export interface IChatHookPart { + kind: 'hook'; + /** The type of hook that was executed */ + hookType: HookTypeValue; + /** If set, the hook blocked processing. This message is shown to the user. */ + stopReason?: string; + /** Warning/system message from the hook, shown to the user */ + systemMessage?: string; + metadata?: { readonly [key: string]: unknown }; +} + export interface IChatTerminalToolInvocationData { kind: 'terminal'; commandLine: { @@ -932,7 +949,8 @@ export type IChatProgress = | IChatElicitationRequest | IChatElicitationRequestSerialized | IChatMcpServersStarting - | IChatMcpServersStartingSerialized; + | IChatMcpServersStartingSerialized + | IChatHookPart; export interface IChatFollowup { kind: 'reply'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 84428a5a4582f..d78cefbe85f14 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -737,7 +737,7 @@ export class ChatService extends Disposable implements IChatService { const requestModel = new ChatRequestModel({ session: model, message: parsedRequest, - variableData: { variables: [] }, + variableData: { variables: options.attachedContext ?? [] }, timestamp: Date.now(), modeInfo: options.modeInfo, locationData: options.locationData, @@ -776,12 +776,9 @@ export class ChatService extends Disposable implements IChatService { const hasPendingRequest = this._pendingRequests.has(sessionResource); const hasPendingQueue = model.getPendingRequests().length > 0; - if (hasPendingRequest) { - // A request is already in progress - if (options?.queue) { - // Queue this message to be sent after the current request completes - return this.queuePendingRequest(model, sessionResource, request, options); - } + if (options?.queue) { + return this.queuePendingRequest(model, sessionResource, request, options); + } else if (hasPendingRequest) { this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); return { kind: 'rejected', reason: 'Request already in progress' }; } @@ -979,6 +976,7 @@ export class ChatService extends Disposable implements IChatService { modeInstructions: options?.modeInfo?.modeInstructions, editedFileEvents: request.editedFileEvents, hooks: collectedHooks, + hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), }; let isInitialTools = true; @@ -1181,7 +1179,12 @@ export class ChatService extends Disposable implements IChatService { const deferred = this._queuedRequestDeferreds.get(pendingRequest.request.id); this._queuedRequestDeferreds.delete(pendingRequest.request.id); - const sendOptions = pendingRequest.sendOptions; + const sendOptions: IChatSendRequestOptions = { + ...pendingRequest.sendOptions, + // Ensure attachedContext is preserved after deserialization, where sendOptions + // loses attachedContext but the request model retains it in variableData. + attachedContext: pendingRequest.request.variableData.variables.slice(), + }; const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation; const defaultAgent = this.chatAgentService.getDefaultAgent(location, sendOptions.modeInfo?.kind); if (!defaultAgent) { diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts index d13e9afec1187..b8f018e8f2336 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts @@ -16,6 +16,8 @@ * Internal types (in hooksTypes.ts) are used within VS Code. */ +import { URI } from '../../../../../base/common/uri.js'; + //#region Common Hook Types /** @@ -23,9 +25,10 @@ */ export interface IHookCommandInput { readonly timestamp: string; - readonly cwd: string; + readonly cwd: URI; readonly sessionId: string; readonly hookEventName: string; + readonly transcript_path?: URI; } /** @@ -85,6 +88,7 @@ export interface IPreToolUseCommandOutput extends IHookCommandOutput { readonly hookEventName?: string; readonly permissionDecision?: 'allow' | 'deny'; readonly permissionDecisionReason?: string; + readonly updatedInput?: object; readonly additionalContext?: string; }; } diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts index 5a7b0431a0c2a..1e7a7a4416a7a 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { URI } from '../../../../../base/common/uri.js'; +import { URI, isUriComponents } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -116,6 +116,8 @@ export class HooksExecutionService extends Disposable implements IHooksExecution private _proxy: IHooksExecutionProxy | undefined; private readonly _sessionHooks = new Map(); + /** Stored transcript path per session (keyed by session URI string). */ + private readonly _sessionTranscriptPaths = new Map(); private _channelRegistered = false; private _requestCounter = 0; @@ -160,20 +162,37 @@ export class HooksExecutionService extends Disposable implements IHooksExecution return result; } + /** + * JSON.stringify replacer that converts URI / UriComponents values to their string form. + */ + private readonly _uriReplacer = (_key: string, value: unknown): unknown => { + if (URI.isUri(value)) { + return value.fsPath; + } + if (isUriComponents(value)) { + return URI.revive(value).fsPath; + } + return value; + }; + private async _runSingleHook( requestId: number, hookType: HookTypeValue, hookCommand: IHookCommand, sessionResource: URI, callerInput: unknown, + transcriptPath: URI | undefined, token: CancellationToken ): Promise { - // Build the common hook input properties + // Build the common hook input properties. + // URI values are kept as URI objects through the RPC boundary, and converted + // to filesystem paths on the extension host side during JSON serialization. const commonInput: IHookCommandInput = { timestamp: new Date().toISOString(), - cwd: hookCommand.cwd?.fsPath ?? '', + cwd: hookCommand.cwd ?? URI.file(''), sessionId: sessionResource.toString(), hookEventName: hookType, + ...(transcriptPath ? { transcript_path: transcriptPath } : undefined), }; // Merge common properties with caller-specific input @@ -181,13 +200,10 @@ export class HooksExecutionService extends Disposable implements IHooksExecution ? { ...commonInput, ...callerInput } : commonInput; - const hookCommandJson = JSON.stringify({ - ...hookCommand, - cwd: hookCommand.cwd?.fsPath - }); + const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer); this._log(requestId, hookType, `Running: ${hookCommandJson}`); const inputForLog = this._redactForLogging(fullInput); - this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog)}`); + this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`); const sw = StopWatch.create(); try { @@ -267,11 +283,30 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } + /** + * Extract `transcript_path` from hook input if present. + * The caller (e.g. SessionStart) may include it as a URI in the input object. + */ + private _extractTranscriptPath(input: unknown): URI | undefined { + if (typeof input !== 'object' || input === null) { + return undefined; + } + const transcriptPath = (input as Record)['transcriptPath']; + if (URI.isUri(transcriptPath)) { + return transcriptPath; + } + if (isUriComponents(transcriptPath)) { + return URI.revive(transcriptPath); + } + return undefined; + } + registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { const key = sessionResource.toString(); this._sessionHooks.set(key, hooks); return toDisposable(() => { this._sessionHooks.delete(key); + this._sessionTranscriptPaths.delete(key); }); } @@ -288,6 +323,14 @@ export class HooksExecutionService extends Disposable implements IHooksExecution return results; } + const sessionKey = sessionResource.toString(); + + // Extract and store transcript_path from input when present (e.g. SessionStart) + const inputTranscriptPath = this._extractTranscriptPath(options?.input); + if (inputTranscriptPath) { + this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath); + } + const hooks = this.getHooksForSession(sessionResource); if (!hooks) { return results; @@ -298,6 +341,8 @@ export class HooksExecutionService extends Disposable implements IHooksExecution return results; } + const transcriptPath = this._sessionTranscriptPaths.get(sessionKey); + const requestId = this._requestCounter++; const token = options?.token ?? CancellationToken.None; @@ -305,7 +350,7 @@ export class HooksExecutionService extends Disposable implements IHooksExecution this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); for (const hookCommand of hookCommands) { - const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, token); + const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token); results.push(result); // If stopReason is set, stop processing remaining hooks @@ -345,6 +390,7 @@ export class HooksExecutionService extends Disposable implements IHooksExecution let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined; let winningResult: IHookResult | undefined; let winningReason: string | undefined; + let lastUpdatedInput: object | undefined; for (const result of results) { if (result.success && typeof result.output === 'object' && result.output !== null) { @@ -363,6 +409,11 @@ export class HooksExecutionService extends Disposable implements IHooksExecution allAdditionalContext.push(hookSpecificOutput.additionalContext); } + // Track the last updatedInput (later hooks override earlier ones) + if (hookSpecificOutput.updatedInput) { + lastUpdatedInput = hookSpecificOutput.updatedInput; + } + // Track the most restrictive decision: deny > ask > allow const decision = hookSpecificOutput.permissionDecision; if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) { @@ -377,14 +428,16 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } - if (!mostRestrictiveDecision || !winningResult) { + if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) { return undefined; } + const baseResult = winningResult ?? results[0]; return { - ...winningResult, + ...baseResult, permissionDecision: mostRestrictiveDecision, permissionDecisionReason: winningReason, + updatedInput: lastUpdatedInput, additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, }; } diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts index e562a1096c09c..33c98e7b67ddd 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts @@ -16,7 +16,7 @@ * External types (in hooksCommandTypes.ts) define the contract with spawned commands. */ -import { vEnum, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; +import { vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js'; //#region Common Hook Types @@ -67,8 +67,9 @@ export interface IPreToolUseCallerInput { export const preToolUseOutputValidator = vObj({ hookSpecificOutput: vOptionalProp(vObj({ hookEventName: vOptionalProp(vString()), - permissionDecision: vEnum('allow', 'deny', 'ask'), + permissionDecision: vOptionalProp(vEnum('allow', 'deny', 'ask')), permissionDecisionReason: vOptionalProp(vString()), + updatedInput: vOptionalProp(vObjAny()), additionalContext: vOptionalProp(vString()), })), }); @@ -88,6 +89,12 @@ export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; export interface IPreToolUseHookResult extends IHookResult { readonly permissionDecision?: PreToolUsePermissionDecision; readonly permissionDecisionReason?: string; + /** + * Modified tool input parameters from the hook. + * When set, replaces the original tool input before execution. + * Combine with 'allow' to auto-approve, or 'ask' to show modified input to the user. + */ + readonly updatedInput?: object; readonly additionalContext?: string[]; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index fb343cc6bc229..e5f6b11c795ea 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; @@ -188,6 +188,7 @@ export type IChatProgressHistoryResponseContent = | IChatQuestionCarousel | IChatExtensionsContent | IChatThinkingPart + | IChatHookPart | IChatPullRequestContent | IChatWorkspaceEdit; @@ -495,6 +496,7 @@ class AbstractResponse implements IResponse { case 'elicitation2': case 'elicitationSerialized': case 'thinking': + case 'hook': case 'multiDiffData': case 'mcpServersStarting': case 'questionCarousel': diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 8521cf28f6226..b1b876d8f1f41 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -69,6 +69,7 @@ const responsePartSchema = Adapt.v; - // Check version - if (root.version !== 1) { - return result; - } - const hooks = root.hooks; if (!hooks || typeof hooks !== 'object') { return result; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 3483bf2fb6437..9655530d7df2d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { isAbsolute } from '../../../../../base/common/path.js'; import { untildify } from '../../../../../base/common/labels.js'; +import * as platform from '../../../../../base/common/platform.js'; /** * Enum of available hook types that can be configured in hooks.json @@ -76,14 +77,22 @@ export interface IHookCommand { readonly type: 'command'; /** Cross-platform command to execute. */ readonly command?: string; - /** Bash-specific command. */ - readonly bash?: string; - /** PowerShell-specific command. */ - readonly powershell?: string; + /** Windows-specific command override. */ + readonly windows?: string; + /** Linux-specific command override. */ + readonly linux?: string; + /** macOS-specific command override. */ + readonly osx?: string; /** Resolved working directory URI. */ readonly cwd?: URI; readonly env?: Record; readonly timeoutSec?: number; + /** Original JSON field name that provided the windows command. */ + readonly windowsSource?: 'windows' | 'powershell'; + /** Original JSON field name that provided the linux command. */ + readonly linuxSource?: 'linux' | 'bash'; + /** Original JSON field name that provided the osx command. */ + readonly osxSource?: 'osx' | 'bash'; } /** @@ -106,14 +115,17 @@ export interface IChatRequestHooks { */ const hookCommandSchema: IJSONSchema = { type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type'], anyOf: [ { required: ['command'] }, + { required: ['windows'] }, + { required: ['linux'] }, + { required: ['osx'] }, { required: ['bash'] }, { required: ['powershell'] } ], - errorMessage: nls.localize('hook.commandRequired', 'At least one of "command", "bash", or "powershell" must be specified.'), + errorMessage: nls.localize('hook.commandRequired', 'At least one of "command", "windows", "linux", or "osx" must be specified.'), properties: { type: { type: 'string', @@ -122,15 +134,19 @@ const hookCommandSchema: IJSONSchema = { }, command: { type: 'string', - description: nls.localize('hook.command', 'The command to execute. This is the recommended way to specify commands and works cross-platform.') + description: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.') }, - bash: { + windows: { type: 'string', - description: nls.localize('hook.bash', 'Path to a bash script or an inline bash command. Use for Unix-specific commands when cross-platform "command" is not sufficient.') + description: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.') }, - powershell: { + linux: { type: 'string', - description: nls.localize('hook.powershell', 'Path to a PowerShell script or an inline PowerShell command. Use for Windows-specific commands when cross-platform "command" is not sufficient.') + description: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.') + }, + osx: { + type: 'string', + description: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.') }, cwd: { type: 'string', @@ -158,14 +174,9 @@ export const hookFileSchema: IJSONSchema = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', description: nls.localize('hookFile.description', 'GitHub Copilot hook configuration file. Hooks enable executing custom shell commands at strategic points in an agent\'s workflow.'), - additionalProperties: false, - required: ['version', 'hooks'], + additionalProperties: true, + required: ['hooks'], properties: { - version: { - type: 'number', - enum: [1], - description: nls.localize('hookFile.version', 'Schema version. Must be 1.') - }, hooks: { type: 'object', description: nls.localize('hookFile.hooks', 'Hook definitions organized by type.'), @@ -207,15 +218,14 @@ export const hookFileSchema: IJSONSchema = { label: nls.localize('hookFile.snippet.basic', 'Basic hook configuration'), description: nls.localize('hookFile.snippet.basic.description', 'A basic hook configuration with common hooks'), body: { - version: 1, hooks: { - sessionStart: [ + SessionStart: [ { type: 'command', - command: '${1:echo "Session started"}' + command: '${1:echo "Session started" >> session.log}', } ], - preToolUse: [ + PreToolUse: [ { type: 'command', command: '${2:./scripts/validate.sh}', @@ -252,45 +262,148 @@ export function toHookType(rawHookTypeId: string): HookType | undefined { /** * Normalizes a raw hook command object, validating structure. + * Maps legacy bash/powershell fields to platform-specific overrides: + * - bash -> linux + osx + * - powershell -> windows * This is an internal helper - use resolveHookCommand for the full resolution. */ -function normalizeHookCommand(raw: Record): { command?: string; bash?: string; powershell?: string; cwd?: string; env?: Record; timeoutSec?: number } | undefined { +function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeoutSec?: number } | undefined { if (raw.type !== 'command') { return undefined; } const hasCommand = typeof raw.command === 'string' && raw.command.length > 0; - const hasBash = typeof raw.bash === 'string' && raw.bash.length > 0; - const hasPowerShell = typeof raw.powershell === 'string' && raw.powershell.length > 0; + const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0; + const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0; + + // Platform overrides can be strings directly + const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0; + const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0; + const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0; + + // Map bash -> linux + osx (if not already specified) + // Map powershell -> windows (if not already specified) + const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined); + const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined); + const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined); + + // Track source field names for editor focus (which JSON field to highlight) + const windowsSource: 'windows' | 'powershell' | undefined = hasWindows ? 'windows' : (hasPowerShell ? 'powershell' : undefined); + const linuxSource: 'linux' | 'bash' | undefined = hasLinux ? 'linux' : (hasBash ? 'bash' : undefined); + const osxSource: 'osx' | 'bash' | undefined = hasOsx ? 'osx' : (hasBash ? 'bash' : undefined); return { ...(hasCommand && { command: raw.command as string }), - ...(hasBash && { bash: raw.bash as string }), - ...(hasPowerShell && { powershell: raw.powershell as string }), + ...(windows && { windows }), + ...(linux && { linux }), + ...(osx && { osx }), + ...(windowsSource && { windowsSource }), + ...(linuxSource && { linuxSource }), + ...(osxSource && { osxSource }), ...(typeof raw.cwd === 'string' && { cwd: raw.cwd }), ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), ...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }), }; } +/** + * Gets a label for the current platform. + */ +export function getCurrentPlatformLabel(): string { + if (platform.isWindows) { + return 'Windows'; + } else if (platform.isMacintosh) { + return 'macOS'; + } else if (platform.isLinux) { + return 'Linux'; + } + return ''; +} + +/** + * Resolves the effective command for the current platform. + * This applies OS-specific overrides (windows, linux, osx) to get the actual command that will be executed. + * Similar to how launch.json handles platform-specific configurations in debugAdapter.ts. + */ +export function resolveEffectiveCommand(hook: IHookCommand): string | undefined { + // Select the platform-specific override based on the current OS + if (platform.isWindows && hook.windows) { + return hook.windows; + } else if (platform.isMacintosh && hook.osx) { + return hook.osx; + } else if (platform.isLinux && hook.linux) { + return hook.linux; + } + + // Fall back to the default command + return hook.command; +} + +/** + * Checks if the hook is using a platform-specific command override. + */ +export function isUsingPlatformOverride(hook: IHookCommand): boolean { + if (platform.isWindows && hook.windows) { + return true; + } else if (platform.isMacintosh && hook.osx) { + return true; + } else if (platform.isLinux && hook.linux) { + return true; + } + return false; +} + +/** + * Gets the source shell type for the effective command on the current platform. + * Returns 'powershell' if the Windows command came from a powershell field, + * 'bash' if the Linux/macOS command came from a bash field, + * or undefined for default shell handling. + */ +export function getEffectiveCommandSource(hook: IHookCommand): 'powershell' | 'bash' | undefined { + if (platform.isWindows && hook.windows && hook.windowsSource === 'powershell') { + return 'powershell'; + } else if (platform.isMacintosh && hook.osx && hook.osxSource === 'bash') { + return 'bash'; + } else if (platform.isLinux && hook.linux && hook.linuxSource === 'bash') { + return 'bash'; + } + return undefined; +} + +/** + * Gets the original JSON field key name for the current platform's command. + * Returns the actual field name from the JSON (e.g., 'bash' instead of 'osx' if bash was used). + * This is used for editor focus to highlight the correct field. + */ +export function getEffectiveCommandFieldKey(hook: IHookCommand): string { + if (platform.isWindows && hook.windows) { + return hook.windowsSource ?? 'windows'; + } else if (platform.isMacintosh && hook.osx) { + return hook.osxSource ?? 'osx'; + } else if (platform.isLinux && hook.linux) { + return hook.linuxSource ?? 'linux'; + } + return 'command'; +} + /** * Formats a hook command for display. - * If `command` is present, returns just that value. - * Otherwise, joins "bash: " and "powershell: " with " | ". + * Resolves OS-specific overrides to show the effective command for the current platform. + * If using a platform-specific override, includes the platform as a prefix badge. */ export function formatHookCommandLabel(hook: IHookCommand): string { - if (hook.command) { - return hook.command; + const command = resolveEffectiveCommand(hook) ?? ''; + if (!command) { + return ''; } - const parts: string[] = []; - if (hook.bash) { - parts.push(`bash: ${hook.bash}`); + // Add platform badge if using platform-specific override + if (isUsingPlatformOverride(hook)) { + const platformLabel = getCurrentPlatformLabel(); + return `[${platformLabel}] ${command}`; } - if (hook.powershell) { - parts.push(`powershell: ${hook.powershell}`); - } - return parts.join(' | '); + + return command; } /** @@ -324,8 +437,12 @@ export function resolveHookCommand(raw: Record, workspaceRootUr return { type: 'command', ...(normalized.command && { command: normalized.command }), - ...(normalized.bash && { bash: normalized.bash }), - ...(normalized.powershell && { powershell: normalized.powershell }), + ...(normalized.windows && { windows: normalized.windows }), + ...(normalized.linux && { linux: normalized.linux }), + ...(normalized.osx && { osx: normalized.osx }), + ...(normalized.windowsSource && { windowsSource: normalized.windowsSource }), + ...(normalized.linuxSource && { linuxSource: normalized.linuxSource }), + ...(normalized.osxSource && { osxSource: normalized.osxSource }), ...(cwdUri && { cwd: cwdUri }), ...(normalized.env && { env: normalized.env }), ...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }), diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index e6ae43c0709ac..aecd757949d43 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -1320,23 +1320,6 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } - // Validate version field for Copilot hooks.json format - const filename = basename(uri).toLowerCase(); - if (filename === 'hooks.json' && json.version !== 1) { - files.push({ - uri, - storage, - status: 'skipped', - skipReason: 'parse-error', - errorMessage: json.version === undefined - ? 'Missing version field (expected: 1)' - : `Invalid version: ${json.version} (expected: 1)`, - name, - extensionId - }); - continue; - } - // File is valid files.push({ uri, storage, status: 'loaded', name, extensionId }); } catch (e) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index b99a9c3138ddd..ea79ad2f353c8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -54,21 +54,21 @@ suite('sessionDateFromNow', () => { const ONE_DAY = 24 * 60 * 60 * 1000; - test('returns "1 day ago" for yesterday', () => { + test('returns "1 day" for yesterday', () => { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); // Time in the middle of yesterday const yesterday = startOfToday - ONE_DAY / 2; - assert.strictEqual(sessionDateFromNow(yesterday), '1 day ago'); + assert.strictEqual(sessionDateFromNow(yesterday), '1 day'); }); - test('returns "2 days ago" for two days ago', () => { + test('returns "2 days" for two days ago', () => { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); const startOfYesterday = startOfToday - ONE_DAY; // Time in the middle of two days ago const twoDaysAgo = startOfYesterday - ONE_DAY / 2; - assert.strictEqual(sessionDateFromNow(twoDaysAgo), '2 days ago'); + assert.strictEqual(sessionDateFromNow(twoDaysAgo), '2 days'); }); test('returns fromNow result for today', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index 1496089cf8f2b..617a4ae433c94 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -33,7 +33,6 @@ suite('hookUtils', () => { suite('simple format', () => { const simpleFormat = `{ - "version": 1, "hooks": { "SessionStart": [ { @@ -59,9 +58,9 @@ suite('hookUtils', () => { assert.ok(result); assert.strictEqual(getSelectedText(simpleFormat, result), 'echo first'); assert.deepStrictEqual(result, { - startLineNumber: 7, + startLineNumber: 6, startColumn: 17, - endLineNumber: 7, + endLineNumber: 6, endColumn: 27 }); }); @@ -71,9 +70,9 @@ suite('hookUtils', () => { assert.ok(result); assert.strictEqual(getSelectedText(simpleFormat, result), 'echo second'); assert.deepStrictEqual(result, { - startLineNumber: 11, + startLineNumber: 10, startColumn: 17, - endLineNumber: 11, + endLineNumber: 10, endColumn: 28 }); }); @@ -83,9 +82,9 @@ suite('hookUtils', () => { assert.ok(result); assert.strictEqual(getSelectedText(simpleFormat, result), 'echo foo > test.derp'); assert.deepStrictEqual(result, { - startLineNumber: 17, + startLineNumber: 16, startColumn: 17, - endLineNumber: 17, + endLineNumber: 16, endColumn: 37 }); }); @@ -249,7 +248,7 @@ suite('hookUtils', () => { }); test('returns undefined when hooks key is missing', () => { - const content = '{ "version": 1 }'; + const content = '{ "other": 1 }'; const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); assert.strictEqual(result, undefined); }); @@ -346,7 +345,6 @@ suite('hookUtils', () => { suite('simple format', () => { const simpleFormat = `{ - "version": 1, "hooks": { "sessionStart": [ { @@ -372,9 +370,9 @@ suite('hookUtils', () => { assert.ok(result); assert.strictEqual(getSelectedText(simpleFormat, result), 'echo first'); assert.deepStrictEqual(result, { - startLineNumber: 7, + startLineNumber: 6, startColumn: 17, - endLineNumber: 7, + endLineNumber: 6, endColumn: 27 }); }); @@ -384,9 +382,9 @@ suite('hookUtils', () => { assert.ok(result); assert.strictEqual(getSelectedText(simpleFormat, result), 'echo second'); assert.deepStrictEqual(result, { - startLineNumber: 11, + startLineNumber: 10, startColumn: 17, - endLineNumber: 11, + endLineNumber: 10, endColumn: 28 }); }); @@ -396,9 +394,9 @@ suite('hookUtils', () => { assert.ok(result); assert.strictEqual(getSelectedText(simpleFormat, result), 'echo foo > test.derp'); assert.deepStrictEqual(result, { - startLineNumber: 17, + startLineNumber: 16, startColumn: 17, - endLineNumber: 17, + endLineNumber: 16, endColumn: 37 }); }); @@ -562,7 +560,7 @@ suite('hookUtils', () => { }); test('returns undefined when hooks key is missing', () => { - const content = '{ "version": 1 }'; + const content = '{ "other": 1 }'; const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); assert.strictEqual(result, undefined); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 0c19f89c566de..a9ea936591325 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -14,6 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; @@ -147,6 +148,7 @@ interface TestToolsServiceOptions { accessibilitySignalService?: Partial; telemetryService?: Partial; hooksExecutionService?: MockHooksExecutionService; + commandService?: Partial; /** Called after configurationService is created but before the service is instantiated */ configureServices?: (config: TestConfigurationService) => void; } @@ -181,6 +183,9 @@ function createTestToolsService(store: ReturnType { assert.strictEqual(result.content[0].kind, 'text'); assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); }); + + test('when hook returns updatedInput, tool is invoked with replaced parameters', async () => { + let receivedParameters: Record | undefined; + mockHooksService.preToolUseHookResult = { + output: undefined, + success: true, + permissionDecision: 'allow', + updatedInput: { safeCommand: 'echo hello' }, + }; + + const tool = registerToolForTest(hookService, store, 'hookUpdatedInputTool', { + invoke: async (dto) => { + receivedParameters = dto.parameters; + return { content: [{ kind: 'text', value: 'done' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm?', + message: 'Confirm action', + allowAutoConfirm: true + } + }) + }); + + stubGetSession(hookChatService, 'hook-test-updated-input', { requestId: 'req1' }); + + await hookService.invokeTool( + tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }), + async () => 0, + CancellationToken.None + ); + + assert.deepStrictEqual(receivedParameters, { safeCommand: 'echo hello' }); + }); + + test('when hook returns updatedInput that fails schema validation, original parameters are kept', async () => { + const mockCommandService = { + executeCommand: async (commandId: string) => { + if (commandId === 'json.validate') { + return [{ message: 'Missing required property "command"', range: [{ line: 0, character: 0 }, { line: 0, character: 1 }], severity: 'Error' }]; + } + return undefined; + } + }; + + const mockHooks = new MockHooksExecutionService(); + const setup = createTestToolsService(store, { + hooksExecutionService: mockHooks, + commandService: mockCommandService as ICommandService, + }); + + let receivedParameters: Record | undefined; + mockHooks.preToolUseHookResult = { + output: undefined, + success: true, + permissionDecision: 'allow', + updatedInput: { invalidField: 'wrong' }, + }; + + const tool = registerToolForTest(setup.service, store, 'hookValidationFailTool', { + invoke: async (dto) => { + receivedParameters = dto.parameters; + return { content: [{ kind: 'text', value: 'done' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm?', + message: 'Confirm action', + allowAutoConfirm: true + } + }) + }, { + inputSchema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + } + }); + + stubGetSession(setup.chatService, 'hook-test-validation-fail', { requestId: 'req1' }); + + await setup.service.invokeTool( + tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }), + async () => 0, + CancellationToken.None + ); + + // Original parameters should be kept since validation failed + assert.deepStrictEqual(receivedParameters, { command: 'original' }); + }); + + test('when hook returns updatedInput that passes schema validation, parameters are replaced', async () => { + const mockCommandService = { + executeCommand: async (commandId: string) => { + if (commandId === 'json.validate') { + return []; // no diagnostics = valid + } + return undefined; + } + }; + + const mockHooks = new MockHooksExecutionService(); + const setup = createTestToolsService(store, { + hooksExecutionService: mockHooks, + commandService: mockCommandService as ICommandService, + }); + + let receivedParameters: Record | undefined; + mockHooks.preToolUseHookResult = { + output: undefined, + success: true, + permissionDecision: 'allow', + updatedInput: { command: 'safe-command' }, + }; + + const tool = registerToolForTest(setup.service, store, 'hookValidationPassTool', { + invoke: async (dto) => { + receivedParameters = dto.parameters; + return { content: [{ kind: 'text', value: 'done' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm?', + message: 'Confirm action', + allowAutoConfirm: true + } + }) + }, { + inputSchema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + } + }); + + stubGetSession(setup.chatService, 'hook-test-validation-pass', { requestId: 'req1' }); + + await setup.service.invokeTool( + tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }), + async () => 0, + CancellationToken.None + ); + + // Updated parameters should be applied since validation passed + assert.deepStrictEqual(receivedParameters, { command: 'safe-command' }); + }); }); suite('postToolUse hooks', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts index c50fc0efda40c..5f11ba7491cdc 100644 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -494,6 +494,111 @@ suite('HooksExecutionService', () => { assert.ok(result); assert.strictEqual(result.permissionDecision, 'allow'); }); + + test('returns updatedInput when hook provides it', async () => { + const proxy = createMockProxy(() => ({ + kind: HookCommandResultKind.Success, + result: { + hookSpecificOutput: { + permissionDecision: 'allow', + updatedInput: { path: '/safe/path.ts' } + } + } + })); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const result = await service.executePreToolUseHook( + sessionUri, + { toolName: 'test-tool', toolInput: { path: '/original/path.ts' }, toolCallId: 'call-1' } + ); + + assert.ok(result); + assert.strictEqual(result.permissionDecision, 'allow'); + assert.deepStrictEqual(result.updatedInput, { path: '/safe/path.ts' }); + }); + + test('later hook updatedInput overrides earlier one', async () => { + let callCount = 0; + const proxy = createMockProxy(() => { + callCount++; + if (callCount === 1) { + return { + kind: HookCommandResultKind.Success, + result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } } + }; + } + return { + kind: HookCommandResultKind.Success, + result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } } + }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const result = await service.executePreToolUseHook( + sessionUri, + { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } + ); + + assert.ok(result); + assert.deepStrictEqual(result.updatedInput, { value: 'second' }); + }); + + test('returns result with updatedInput even without permission decision', async () => { + const proxy = createMockProxy(() => ({ + kind: HookCommandResultKind.Success, + result: { + hookSpecificOutput: { + updatedInput: { modified: true } + } + } + })); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const result = await service.executePreToolUseHook( + sessionUri, + { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } + ); + + assert.ok(result); + assert.deepStrictEqual(result.updatedInput, { modified: true }); + assert.strictEqual(result.permissionDecision, undefined); + }); + + test('updatedInput combined with ask shows modified input to user', async () => { + const proxy = createMockProxy(() => ({ + kind: HookCommandResultKind.Success, + result: { + hookSpecificOutput: { + permissionDecision: 'ask', + permissionDecisionReason: 'Modified input needs review', + updatedInput: { command: 'echo safe' } + } + } + })); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const result = await service.executePreToolUseHook( + sessionUri, + { toolName: 'test-tool', toolInput: { command: 'rm -rf /' }, toolCallId: 'call-1' } + ); + + assert.ok(result); + assert.strictEqual(result.permissionDecision, 'ask'); + assert.strictEqual(result.permissionDecisionReason, 'Modified input needs review'); + assert.deepStrictEqual(result.updatedInput, { command: 'echo safe' }); + }); }); suite('executePostToolUseHook', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index 5e11ce1080901..6b73e1d362e5e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -5,8 +5,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { resolveHookCommand } from '../../../common/promptSyntax/hookSchema.js'; +import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand } from '../../../common/promptSyntax/hookSchema.js'; import { URI } from '../../../../../../base/common/uri.js'; +import * as platform from '../../../../../../base/common/platform.js'; suite('HookSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -57,15 +58,18 @@ suite('HookSchema', () => { }); }); - suite('bash shorthand', () => { - test('preserves bash property', () => { + suite('bash legacy mapping', () => { + test('bash maps to linux and osx', () => { const result = resolveHookCommand({ type: 'command', bash: 'echo "hello world"' }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', - bash: 'echo "hello world"', + linux: 'echo "hello world"', + osx: 'echo "hello world"', + linuxSource: 'bash', + osxSource: 'bash', cwd: workspaceRoot }); }); @@ -79,13 +83,16 @@ suite('HookSchema', () => { }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', - bash: './test.sh', + linux: './test.sh', + osx: './test.sh', + linuxSource: 'bash', + osxSource: 'bash', cwd: URI.file('/workspace/scripts'), env: { DEBUG: '1' } }); }); - test('empty bash returns object without bash', () => { + test('empty bash returns object without platform overrides', () => { const result = resolveHookCommand({ type: 'command', bash: '' @@ -97,15 +104,16 @@ suite('HookSchema', () => { }); }); - suite('powershell shorthand', () => { - test('preserves powershell property', () => { + suite('powershell legacy mapping', () => { + test('powershell maps to windows', () => { const result = resolveHookCommand({ type: 'command', powershell: 'Write-Host "hello"' }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', - powershell: 'Write-Host "hello"', + windows: 'Write-Host "hello"', + windowsSource: 'powershell', cwd: workspaceRoot }); }); @@ -118,13 +126,14 @@ suite('HookSchema', () => { }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', - powershell: 'Get-Process', + windows: 'Get-Process', + windowsSource: 'powershell', cwd: workspaceRoot, timeoutSec: 30 }); }); - test('empty powershell returns object without powershell', () => { + test('empty powershell returns object without windows', () => { const result = resolveHookCommand({ type: 'command', powershell: '' @@ -137,7 +146,7 @@ suite('HookSchema', () => { }); suite('multiple properties specified', () => { - test('preserves both command and bash', () => { + test('preserves command with bash mapped to linux/osx', () => { const result = resolveHookCommand({ type: 'command', command: 'direct-command', @@ -146,12 +155,15 @@ suite('HookSchema', () => { assert.deepStrictEqual(result, { type: 'command', command: 'direct-command', - bash: 'bash-script.sh', + linux: 'bash-script.sh', + osx: 'bash-script.sh', + linuxSource: 'bash', + osxSource: 'bash', cwd: workspaceRoot }); }); - test('preserves both command and powershell', () => { + test('preserves command with powershell mapped to windows', () => { const result = resolveHookCommand({ type: 'command', command: 'direct-command', @@ -160,12 +172,13 @@ suite('HookSchema', () => { assert.deepStrictEqual(result, { type: 'command', command: 'direct-command', - powershell: 'ps-script.ps1', + windows: 'ps-script.ps1', + windowsSource: 'powershell', cwd: workspaceRoot }); }); - test('preserves both bash and powershell when no command', () => { + test('bash and powershell map to all platforms', () => { const result = resolveHookCommand({ type: 'command', bash: 'bash-script.sh', @@ -173,8 +186,12 @@ suite('HookSchema', () => { }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', - bash: 'bash-script.sh', - powershell: 'ps-script.ps1', + windows: 'ps-script.ps1', + linux: 'bash-script.sh', + osx: 'bash-script.sh', + windowsSource: 'powershell', + linuxSource: 'bash', + osxSource: 'bash', cwd: workspaceRoot }); }); @@ -223,7 +240,7 @@ suite('HookSchema', () => { assert.strictEqual(result, undefined); }); - test('no command/bash/powershell returns object with just type and cwd', () => { + test('no command returns object with just type and cwd', () => { const result = resolveHookCommand({ type: 'command', cwd: '/workspace' @@ -273,5 +290,209 @@ suite('HookSchema', () => { }); }); }); + + suite('platform-specific overrides', () => { + test('preserves windows override as string', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'default-command', + windows: 'win-command' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'default-command', + windows: 'win-command', + windowsSource: 'windows', + cwd: workspaceRoot + }); + }); + + test('preserves linux override as string', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'default-command', + linux: 'linux-command' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'default-command', + linux: 'linux-command', + linuxSource: 'linux', + cwd: workspaceRoot + }); + }); + + test('preserves osx override as string', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'default-command', + osx: 'osx-command' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'default-command', + osx: 'osx-command', + osxSource: 'osx', + cwd: workspaceRoot + }); + }); + + test('preserves all platform overrides', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'default-command', + windows: 'win-command', + linux: 'linux-command', + osx: 'osx-command' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'default-command', + windows: 'win-command', + linux: 'linux-command', + osx: 'osx-command', + windowsSource: 'windows', + linuxSource: 'linux', + osxSource: 'osx', + cwd: workspaceRoot + }); + }); + + test('explicit platform override takes precedence over bash/powershell mapping', () => { + const result = resolveHookCommand({ + type: 'command', + bash: 'default.sh', + linux: 'explicit-linux.sh' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + linux: 'explicit-linux.sh', + osx: 'default.sh', + linuxSource: 'linux', + osxSource: 'bash', + cwd: workspaceRoot + }); + }); + + test('ignores empty platform override', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'default-command', + windows: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'default-command', + cwd: workspaceRoot + }); + }); + + test('ignores non-string platform override', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'default-command', + windows: { command: 'invalid' } + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'default-command', + cwd: workspaceRoot + }); + }); + }); + }); + + suite('resolveEffectiveCommand', () => { + test('returns base command when no platform override', () => { + const hook: IHookCommand = { + type: 'command', + command: 'default-command' + }; + const result = resolveEffectiveCommand(hook); + assert.strictEqual(result, 'default-command'); + }); + + test('applies current platform override', () => { + const hook: IHookCommand = { + type: 'command', + command: 'default-command', + windows: 'win-command', + linux: 'linux-command', + osx: 'osx-command' + }; + const result = resolveEffectiveCommand(hook); + // Result depends on the current platform + if (platform.isWindows) { + assert.strictEqual(result, 'win-command'); + } else if (platform.isMacintosh) { + assert.strictEqual(result, 'osx-command'); + } else if (platform.isLinux) { + assert.strictEqual(result, 'linux-command'); + } + }); + + test('falls back to command when no platform-specific override', () => { + const hook: IHookCommand = { + type: 'command', + command: 'default-command' + }; + const result = resolveEffectiveCommand(hook); + assert.strictEqual(result, 'default-command'); + }); + + test('returns undefined when no command at all', () => { + const hook: IHookCommand = { + type: 'command' + }; + const result = resolveEffectiveCommand(hook); + assert.strictEqual(result, undefined); + }); + }); + + suite('formatHookCommandLabel', () => { + test('formats command when present (no platform override)', () => { + const hook: IHookCommand = { + type: 'command', + command: 'echo hello' + }; + // No platform badge when using default command + assert.strictEqual(formatHookCommandLabel(hook), 'echo hello'); + }); + + test('returns empty string when no command', () => { + const hook: IHookCommand = { + type: 'command' + }; + assert.strictEqual(formatHookCommandLabel(hook), ''); + }); + + test('applies platform override for display with platform badge', () => { + const hook: IHookCommand = { + type: 'command', + command: 'default-command', + windows: 'win-command', + linux: 'linux-command', + osx: 'osx-command' + }; + const label = formatHookCommandLabel(hook); + // Should include platform badge when using platform-specific override + if (platform.isWindows) { + assert.strictEqual(label, '[Windows] win-command'); + } else if (platform.isMacintosh) { + assert.strictEqual(label, '[macOS] osx-command'); + } else if (platform.isLinux) { + assert.strictEqual(label, '[Linux] linux-command'); + } + }); + + test('no platform badge when falling back to default command', () => { + const hook: IHookCommand = { + type: 'command', + command: 'default-command' + // No platform-specific overrides + }; + // Should not include badge when using default command + assert.strictEqual(formatHookCommandLabel(hook), 'default-command'); + }); }); }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 7dad5df8d18e3..c897cfe74011b 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -1870,7 +1870,7 @@ export class ExtensionRuntimeStateAction extends ExtensionAction { } else if (runtimeState?.action === ExtensionRuntimeActionType.DownloadUpdate) { - return this.updateService.downloadUpdate(); + return this.updateService.downloadUpdate(true); } else if (runtimeState?.action === ExtensionRuntimeActionType.ApplyUpdate) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 38c377fc8407b..93042988035cf 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -74,6 +74,7 @@ import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlCon import { ExtensionGalleryResourceType, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { fromNow } from '../../../../base/common/date.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; interface IExtensionStateProvider { (extension: Extension): T; @@ -1037,6 +1038,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension @IFileDialogService private readonly fileDialogService: IFileDialogService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IAllowedExtensionsService private readonly allowedExtensionsService: IAllowedExtensionsService, + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService ) { super(); @@ -1128,7 +1130,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } })); this._register(this.extensionEnablementService.onEnablementChanged(platformExtensions => { - if (this.getAutoUpdateValue() === 'onlyEnabledExtensions' && platformExtensions.some(e => this.extensionEnablementService.isEnabled(e))) { + if (this.isAutoCheckUpdatesEnabled() && this.getAutoUpdateValue() === 'onlyEnabledExtensions' && platformExtensions.some(e => this.extensionEnablementService.isEnabled(e))) { this.checkForUpdates('Extension enablement changed'); } })); @@ -1151,6 +1153,15 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } })); + this._register(this.meteredConnectionService.onDidChangeIsConnectionMetered(() => { + if (this.isAutoCheckUpdatesEnabled()) { + this.checkForUpdates('Connection is no longer metered'); + } + if (isWeb && !this.isAutoUpdateEnabled()) { + this.autoUpdateBuiltinExtensions(); + } + })); + // Update AutoUpdate Contexts this.hasOutdatedExtensionsContextKey.set(this.outdated.length > 0); @@ -1174,6 +1185,9 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private isAutoUpdateEnabled(): boolean { + if (this.meteredConnectionService.isConnectionMetered) { + return false; + } return this.getAutoUpdateValue() !== false; } @@ -2073,6 +2087,9 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private isAutoCheckUpdatesEnabled(): boolean { + if (this.meteredConnectionService.isConnectionMetered) { + return false; + } return this.configurationService.getValue(AutoCheckUpdatesConfigurationKey); } @@ -2099,6 +2116,9 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private async autoUpdateBuiltinExtensions(): Promise { + if (this.meteredConnectionService.isConnectionMetered) { + return; + } await this.checkForUpdates(undefined, true); const toUpdate = this.outdated.filter(e => e.isBuiltin); await Promises.settled(toUpdate.map(e => this.install(e, e.local?.preRelease ? { installPreReleaseVersion: true } : undefined))); @@ -2120,6 +2140,11 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } private async autoUpdateExtensions(): Promise { + if (this.meteredConnectionService.isConnectionMetered) { + this.logService.trace('[Extensions]: Skipping auto-update because connection is metered'); + return; + } + const toUpdate: IExtension[] = []; const disabledAutoUpdate = []; const consentRequired = []; diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts index 39d0a70192720..662828fa950d9 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts @@ -66,6 +66,7 @@ import { timeout } from '../../../../../base/common/async.js'; import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); @@ -288,6 +289,7 @@ suite('ExtensionRecommendationsService Test', () => { }); instantiationService.stub(IUpdateService, { onStateChange: Event.None, state: State.Uninitialized }); + instantiationService.stub(IMeteredConnectionService, { isConnectionMetered: false, onDidChangeIsConnectionMetered: Event.None }); instantiationService.set(IExtensionsWorkbenchService, disposableStore.add(instantiationService.createInstance(ExtensionsWorkbenchService))); instantiationService.stub(IExtensionTipsService, disposableStore.add(instantiationService.createInstance(TestExtensionTipsService))); diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsActions.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsActions.test.ts index a085d55b89298..14745b971ecb0 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsActions.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsActions.test.ts @@ -57,6 +57,7 @@ import { platform } from '../../../../../base/common/platform.js'; import { arch } from '../../../../../base/common/process.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { Mutable } from '../../../../../base/common/types.js'; @@ -151,6 +152,7 @@ function setupTest(disposables: Pick) { instantiationService.stub(IUserDataSyncEnablementService, disposables.add(instantiationService.createInstance(UserDataSyncEnablementService))); instantiationService.stub(IUpdateService, { onStateChange: Event.None, state: State.Uninitialized }); + instantiationService.stub(IMeteredConnectionService, { isConnectionMetered: false, onDidChangeIsConnectionMetered: Event.None }); instantiationService.set(IExtensionsWorkbenchService, disposables.add(instantiationService.createInstance(ExtensionsWorkbenchService))); instantiationService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService())); } diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsViews.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsViews.test.ts index 4b5ebfc1d226f..d635e1c0c48e9 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsViews.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsViews.test.ts @@ -49,6 +49,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js'; @@ -216,6 +217,7 @@ suite('ExtensionsViews Tests', () => { await (instantiationService.get(IWorkbenchExtensionEnablementService)).setEnablement([localDisabledLanguage], EnablementState.DisabledGlobally); instantiationService.stub(IUpdateService, { onStateChange: Event.None, state: State.Uninitialized }); + instantiationService.stub(IMeteredConnectionService, { isConnectionMetered: false, onDidChangeIsConnectionMetered: Event.None }); instantiationService.set(IExtensionsWorkbenchService, disposableStore.add(instantiationService.createInstance(ExtensionsWorkbenchService))); testableView = disposableStore.add(instantiationService.createInstance(ExtensionsListView, {}, { id: '', title: '' })); queryPage = aPage([]); diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts index a144b6b1c9514..857a9db45e5df 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts @@ -58,6 +58,7 @@ import { UserDataProfileService } from '../../../../services/userDataProfile/com import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js'; import { toUserDataProfile } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; suite('ExtensionsWorkbenchServiceTest', () => { @@ -147,6 +148,7 @@ suite('ExtensionsWorkbenchServiceTest', () => { instantiationService.stubPromise(INotificationService, 'prompt', 0); (instantiationService.get(IWorkbenchExtensionEnablementService)).reset(); instantiationService.stub(IUpdateService, { onStateChange: Event.None, state: State.Uninitialized }); + instantiationService.stub(IMeteredConnectionService, { isConnectionMetered: false, onDidChangeIsConnectionMetered: Event.None }); }); test('test gallery extension', async () => { diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts new file mode 100644 index 0000000000000..66e27614968c6 --- /dev/null +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnection.contribution.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { MeteredConnectionStatusContribution } from './meteredConnectionStatus.js'; + +import '../../../../platform/meteredConnection/common/meteredConnection.config.contribution.js'; + +registerWorkbenchContribution2(MeteredConnectionStatusContribution.ID, MeteredConnectionStatusContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts new file mode 100644 index 0000000000000..619ecfcf04305 --- /dev/null +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IMeteredConnectionService, METERED_CONNECTION_SETTING_KEY } from '../../../../platform/meteredConnection/common/meteredConnection.js'; +import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; + +export class MeteredConnectionStatusContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.meteredConnectionStatus'; + + private readonly statusBarEntry = this._register(new MutableDisposable()); + + constructor( + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, + @IStatusbarService private readonly statusbarService: IStatusbarService, + ) { + super(); + + this.updateStatusBarEntry(this.meteredConnectionService.isConnectionMetered); + + this._register(this.meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + this.updateStatusBarEntry(isMetered); + })); + } + + private updateStatusBarEntry(isMetered: boolean): void { + if (isMetered) { + if (!this.statusBarEntry.value) { + this.statusBarEntry.value = this.statusbarService.addEntry( + this.getStatusBarEntry(), + MeteredConnectionStatusContribution.ID, + StatusbarAlignment.RIGHT, + -Number.MAX_VALUE // Show at the far right + ); + } + } else { + this.statusBarEntry.clear(); + } + } + + private getStatusBarEntry(): IStatusbarEntry { + return { + name: localize('status.meteredConnection', "Metered Connection"), + text: '$(radio-tower)', + ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Detected"), + tooltip: localize('status.meteredConnection.tooltip', "Metered connection detected. Some automatic features like extension updates, Settings Sync, and automatic Git operations are paused to reduce data usage."), + command: { + id: 'workbench.action.openSettings', + title: localize('status.meteredConnection.configure', "Configure"), + arguments: [`@id:${METERED_CONNECTION_SETTING_KEY}`] + }, + showInAllWindows: true + }; + } +} diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index a166f49f470ec..30b1f1f95242f 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -403,6 +403,11 @@ export const tocData: ITOCEntry = { label: localize('settingsSync', "Settings Sync"), settings: ['settingsSync.*'] }, + { + id: 'application/network', + label: localize('network', "Network"), + settings: ['network.*'] + }, { id: 'application/experimental', label: localize('experimental', "Experimental"), diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index d030ea3557620..b013e577beb14 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -135,7 +135,7 @@ class DownloadUpdateAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - await accessor.get(IUpdateService).downloadUpdate(); + await accessor.get(IUpdateService).downloadUpdate(true); } } diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index 55cc7d3f62e96..f2aaaeaf57a90 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -317,7 +317,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu nls.localize('thereIsUpdateAvailable', "There is an available update."), [{ label: nls.localize('download update', "Download Update"), - run: () => this.updateService.downloadUpdate() + run: () => this.updateService.downloadUpdate(true) }, { label: nls.localize('later', "Later"), run: () => { } @@ -475,7 +475,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu when: CONTEXT_UPDATE_STATE.isEqualTo(StateType.CheckingForUpdates) }); - CommandsRegistry.registerCommand('update.downloadNow', () => this.updateService.downloadUpdate()); + CommandsRegistry.registerCommand('update.downloadNow', () => this.updateService.downloadUpdate(true)); MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '7_update', command: { diff --git a/src/vs/workbench/services/telemetry/browser/telemetryService.ts b/src/vs/workbench/services/telemetry/browser/telemetryService.ts index 85fe8956159ad..f666488665257 100644 --- a/src/vs/workbench/services/telemetry/browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/browser/telemetryService.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ILogService, ILoggerService } from '../../../../platform/log/common/log.js'; +import { ILoggerService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { OneDataSystemWebAppender } from '../../../../platform/telemetry/browser/1dsAppender.js'; @@ -17,6 +17,7 @@ import { ITelemetryServiceConfig, TelemetryService as BaseTelemetryService } fro import { getTelemetryLevel, isInternalTelemetry, isLoggingOnly, ITelemetryAppender, NullTelemetryService, supportsTelemetry } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { resolveWorkbenchCommonProperties } from './workbenchCommonProperties.js'; import { experimentsEnabled } from '../common/workbenchTelemetryUtils.js'; @@ -36,21 +37,21 @@ export class TelemetryService extends Disposable implements ITelemetryService { constructor( @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, - @ILogService logService: ILogService, @ILoggerService loggerService: ILoggerService, @IConfigurationService configurationService: IConfigurationService, @IStorageService storageService: IStorageService, @IProductService productService: IProductService, - @IRemoteAgentService remoteAgentService: IRemoteAgentService + @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService ) { super(); - this.impl = this.initializeService(environmentService, logService, loggerService, configurationService, storageService, productService, remoteAgentService); + this.impl = this.initializeService(environmentService, loggerService, configurationService, storageService, productService, remoteAgentService, meteredConnectionService); // When the level changes it could change from off to on and we want to make sure telemetry is properly intialized this._register(configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TELEMETRY_SETTING_ID)) { - this.impl = this.initializeService(environmentService, logService, loggerService, configurationService, storageService, productService, remoteAgentService); + this.impl = this.initializeService(environmentService, loggerService, configurationService, storageService, productService, remoteAgentService, meteredConnectionService); } })); } @@ -62,12 +63,12 @@ export class TelemetryService extends Disposable implements ITelemetryService { */ private initializeService( environmentService: IBrowserWorkbenchEnvironmentService, - logService: ILogService, loggerService: ILoggerService, configurationService: IConfigurationService, storageService: IStorageService, productService: IProductService, - remoteAgentService: IRemoteAgentService + remoteAgentService: IRemoteAgentService, + meteredConnectionService: IMeteredConnectionService ) { const telemetrySupported = supportsTelemetry(productService, environmentService) && productService.aiConfig?.ariaKey; if (telemetrySupported && getTelemetryLevel(configurationService) !== TelemetryLevel.NONE && this.impl === NullTelemetryService) { @@ -91,6 +92,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { commonProperties: resolveWorkbenchCommonProperties(storageService, productService, isInternal, environmentService.remoteAuthority, environmentService.options && environmentService.options.resolveCommonTelemetryProperties), sendErrorTelemetry: this.sendErrorTelemetry, waitForExperimentProperties: experimentsEnabled(configurationService, productService, environmentService), + meteredConnectionService, }; return this._register(new BaseTelemetryService(config, configurationService, productService)); diff --git a/src/vs/workbench/services/update/browser/updateService.ts b/src/vs/workbench/services/update/browser/updateService.ts index 077fa71fb7c86..611eef4f9463c 100644 --- a/src/vs/workbench/services/update/browser/updateService.ts +++ b/src/vs/workbench/services/update/browser/updateService.ts @@ -82,7 +82,7 @@ export class BrowserUpdateService extends Disposable implements IUpdateService { return undefined; // no update provider to ask } - async downloadUpdate(): Promise { + async downloadUpdate(_explicit: boolean): Promise { // no-op } diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 7239fa186edea..8b07bafcbcec3 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -417,6 +417,9 @@ import './contrib/bracketPairColorizer2Telemetry/browser/bracketPairColorizer2Te // Accessibility import './contrib/accessibility/browser/accessibility.contribution.js'; +// Metered Connection +import './contrib/meteredConnection/browser/meteredConnection.contribution.js'; + // Share import './contrib/share/browser/share.contribution.js'; diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 44c5621cf7db0..e720674e03f88 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -43,6 +43,7 @@ import './services/url/electron-browser/urlService.js'; import './services/lifecycle/electron-browser/lifecycleService.js'; import './services/title/electron-browser/titleService.js'; import './services/host/electron-browser/nativeHostService.js'; +import '../platform/meteredConnection/electron-browser/meteredConnectionService.js'; import './services/request/electron-browser/requestService.js'; import './services/clipboard/electron-browser/clipboardService.js'; import './services/contextmenu/electron-browser/contextmenuService.js'; diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index 0138c8fe2807b..aee7366ed5353 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -52,6 +52,7 @@ import './services/workspaces/browser/workspacesService.js'; import './services/workspaces/browser/workspaceEditingService.js'; import './services/dialogs/browser/fileDialogService.js'; import './services/host/browser/browserHostService.js'; +import '../platform/meteredConnection/browser/meteredConnectionService.js'; import './services/lifecycle/browser/lifecycleService.js'; import './services/clipboard/browser/clipboardService.js'; import './services/localization/browser/localeService.js'; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index defe9953b2e1b..0d9ef00fa3ead 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -384,7 +384,7 @@ declare module 'vscode' { constructor(uris: Uri[], callback: () => Thenable); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseWorkspaceEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart | ChatResponseQuestionCarouselPart; + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseWorkspaceEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart | ChatResponseQuestionCarouselPart | ChatResponseHookPart; export class ChatResponseWarningPart { value: MarkdownString; constructor(value: string | MarkdownString); @@ -413,6 +413,31 @@ declare module 'vscode' { constructor(value: string | string[], id?: string, metadata?: { readonly [key: string]: any }, task?: (progress: Progress) => Thenable); } + /** + * A progress part representing the execution result of a hook. + * Hooks are user-configured scripts that run at specific points during chat processing. + * If {@link stopReason} is set, the hook blocked/denied the operation. + */ + export class ChatResponseHookPart { + /** The type of hook that was executed */ + hookType: ChatHookType; + /** If set, the hook blocked processing. This message is shown to the user. */ + stopReason?: string; + /** Warning/system message from the hook, shown to the user */ + systemMessage?: string; + /** Optional metadata associated with the hook execution */ + metadata?: { readonly [key: string]: unknown }; + + /** + * Creates a new hook progress part. + * @param hookType The type of hook that was executed + * @param stopReason Message shown when processing was stopped + * @param systemMessage Warning/system message from the hook + * @param metadata Optional metadata + */ + constructor(hookType: ChatHookType, stopReason?: string, systemMessage?: string, metadata?: { readonly [key: string]: unknown }); + } + export class ChatResponseReferencePart2 { /** * The reference target. @@ -514,6 +539,14 @@ declare module 'vscode' { thinkingProgress(thinkingDelta: ThinkingDelta): void; + /** + * Push a hook execution result to this stream. + * @param hookType The type of hook that was executed + * @param stopReason If set, the hook blocked processing. This message is shown to the user. + * @param systemMessage Warning/system message from the hook + */ + hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): void; + textEdit(target: Uri, edits: TextEdit | TextEdit[]): void; textEdit(target: Uri, isDone: true): void; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 7b3e24ec14aba..86dcf0337e464 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -115,6 +115,11 @@ declare module 'vscode' { * The request ID of the parent request that invoked this subagent. */ readonly parentRequestId?: string; + + /** + * Whether any hooks are enabled for this request. + */ + readonly hasHooksEnabled: boolean; } export enum ChatRequestEditedFileEventKind { diff --git a/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts b/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts new file mode 100644 index 0000000000000..fc89a57af7ace --- /dev/null +++ b/src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export namespace env { + /** + * Whether the current network connection is metered (such as mobile data or tethering). + * Always returns `false` if the `update.respectMeteredConnections` setting is disabled. + */ + export const isMeteredConnection: boolean; + + /** + * Event that fires when the metered connection status changes. + */ + export const onDidChangeMeteredConnection: Event; + } +}