diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 4645a08fa61b4..73e5b299c2f9f 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -34,7 +34,7 @@ "input.background": "#191A1B", "input.border": "#333536FF", "input.foreground": "#bfbfbf", - "input.placeholderForeground": "#777777", + "input.placeholderForeground": "#555555", "inputOption.activeBackground": "#3994BC33", "inputOption.activeForeground": "#bfbfbf", "inputOption.activeBorder": "#2A2B2CFF", diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index f7168ac83af66..e563293ea2a4b 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -255,6 +255,7 @@ export class MenuId { static readonly ChatExecute = new MenuId('ChatExecute'); static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); + static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index fb796867b8bc2..a9bc2d2fa1092 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -60,7 +60,7 @@ const _allApiProposals = { }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 14 + version: 15 }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 61a6ce830c289..dcbdf9291cf4f 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3440,6 +3440,7 @@ export namespace ChatAgentRequest { editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), + permissionLevel: request.permissionLevel, subAgentInvocationId: request.subAgentInvocationId, subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 4d7b9f7fa8f18..696376a8e982f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -22,11 +22,12 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { getModeNameForTelemetry, IChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js'; import { ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatService } from '../../common/chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { isInClaudeAgentsFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; @@ -440,6 +441,44 @@ export class OpenModelPickerAction extends Action2 { } } } + +export class OpenPermissionPickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openPermissionPicker'; + + constructor() { + super({ + id: OpenPermissionPickerAction.ID, + title: localize2('interactive.openPermissionPicker.label', "Open Permission Picker"), + tooltip: localize('setPermissionLevel', "Set Permissions"), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.ChatInputSecondary, + order: 10, + group: 'navigation', + when: + ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), + ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.lockedToCodingAgent.negate(), + IsSessionsWindowContext.negate(), + ) + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (widget) { + widget.input.openPermissionPicker(); + } + } +} + export class OpenModePickerAction extends Action2 { static readonly ID = 'workbench.action.chat.openModePicker'; @@ -508,6 +547,18 @@ export class OpenSessionTargetPickerAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.chatSessionIsEmpty, + IsSessionsWindowContext), + group: 'navigation', + }, + { + id: MenuId.ChatInputSecondary, + order: 0, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.inQuickChat.negate(), + IsSessionsWindowContext.negate(), ChatContextKeys.chatSessionIsEmpty), group: 'navigation', }, @@ -543,7 +594,19 @@ export class OpenDelegationPickerAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.chatSessionIsEmpty.negate()), + ChatContextKeys.chatSessionIsEmpty.negate(), + IsSessionsWindowContext), + group: 'navigation', + }, + { + id: MenuId.ChatInputSecondary, + order: 0.5, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.chatSessionIsEmpty.negate(), + IsSessionsWindowContext.negate()), group: 'navigation', }, ] @@ -576,7 +639,18 @@ export class OpenWorkspacePickerAction extends Action2 { order: 0.6, when: ContextKeyExpr.and( ChatContextKeys.inAgentSessionsWelcome, - ChatContextKeys.chatSessionType.isEqualTo('local') + ChatContextKeys.chatSessionType.isEqualTo('local'), + IsSessionsWindowContext + ), + group: 'navigation', + }, + { + id: MenuId.ChatInputSecondary, + order: 0.6, + when: ContextKeyExpr.and( + ChatContextKeys.inAgentSessionsWelcome, + ChatContextKeys.chatSessionType.isEqualTo('local'), + IsSessionsWindowContext.negate() ), group: 'navigation', }, @@ -594,7 +668,7 @@ export class ChatSessionPrimaryPickerAction extends Action2 { constructor() { super({ id: ChatSessionPrimaryPickerAction.ID, - title: localize2('interactive.openChatSessionPrimaryPicker.label', "Open Model Picker"), + title: localize2('interactive.openChatSessionPrimaryPicker.label', "Open Primary Session Picker"), category: CHAT_CATEGORY, f1: false, precondition: ChatContextKeys.enabled, @@ -954,6 +1028,7 @@ export function registerChatExecuteActions() { registerAction2(ToggleChatModeAction); registerAction2(SwitchToNextModelAction); registerAction2(OpenModelPickerAction); + registerAction2(OpenPermissionPickerAction); registerAction2(OpenModePickerAction); registerAction2(OpenSessionTargetPickerAction); registerAction2(OpenDelegationPickerAction); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 954b313b84a88..2c38f3c9874da 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -371,6 +371,12 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION_MACHINE, tags: ['experimental', 'advanced'], }, + [ChatConfiguration.AutopilotEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.autopilot.enabled', "Controls whether the Autopilot mode is available in the permissions picker. When enabled, Autopilot auto-approves all tool calls and continues until the task is done."), + default: true, + tags: ['experimental'], + }, [ChatConfiguration.GlobalAutoApprove]: { default: false, markdownDescription: globalAutoApproveDescription.value, diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 7fa8e87b01e28..9b3fc446d6c13 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -289,6 +289,12 @@ export interface IChatWidgetViewOptions { * redirect to a different workspace rather than executing locally. */ submitHandler?: (query: string, mode: ChatModeKind) => Promise; + + /** + * Whether we are running in the sessions window. + * When true, the secondary toolbar (permissions picker) is hidden. + */ + isSessionsWindow?: boolean; } export interface IChatViewViewContext { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 13eb89e07cbaf..d9bc284672ed7 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -40,7 +40,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../common/constants.js'; +import { ChatConfiguration, isAutoApproveLevel } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -641,7 +641,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this.ensureToolDetails(dto, toolResult, tool.data); const afterExecuteState = await toolInvocation?.didExecuteTool(toolResult, undefined, () => - this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource)); + this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource, dto.chatRequestId)); if (toolInvocation && afterExecuteState?.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { const postConfirm = await IChatToolInvocation.awaitPostConfirmation(toolInvocation, token); @@ -791,7 +791,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } // No hook decision - use normal auto-confirm logic - const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource); + const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource, dto.chatRequestId); return { autoConfirmed, preparedInvocation }; } @@ -996,6 +996,15 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo }); } + /** + * Returns true if enterprise policy has explicitly disabled the global auto-approve setting. + * When this is the case, Bypass Approvals and Autopilot permission levels should not auto-approve tools. + */ + private _isAutoApprovePolicyRestricted(): boolean { + const inspected = this._configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + return inspected.policyValue === false; + } + private getEligibleForAutoApprovalSpecialCase(toolData: IToolData): string | undefined { if (toolData.id === 'vscode_fetchWebPage_internal') { return 'fetch'; @@ -1040,12 +1049,22 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } - private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise { + private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { const tool = this._tools.get(toolId); if (!tool) { return undefined; } + // Auto-Approve All permission level bypasses all tool confirmations, + // unless enterprise policy has explicitly disabled global auto-approve. + if (chatSessionResource) { + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) && !this._isAutoApprovePolicyRestricted()) { + return { type: ToolConfirmKind.ConfirmationNotNeeded, reason: 'auto-approve-all' }; + } + } + if (!this.isToolEligibleForAutoApproval(tool.data)) { return undefined; } @@ -1077,7 +1096,17 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise { + private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { + // Auto-Approve All permission level bypasses all post-execution confirmations, + // unless enterprise policy has explicitly disabled global auto-approve. + if (chatSessionResource) { + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) && !this._isAutoApprovePolicyRestricted()) { + return { type: ToolConfirmKind.ConfirmationNotNeeded, reason: 'auto-approve-all' }; + } + } + if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove) && await this._checkGlobalAutoApprove()) { return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove }; } @@ -1186,7 +1215,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Clean up any pending tool calls that belong to this request for (const [toolCallId, invocation] of this._pendingToolCalls) { if (invocation.chatRequestId === requestId) { - invocation.cancelFromStreaming(ToolConfirmKind.Skipped); this._pendingToolCalls.delete(toolCallId); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index b559869616fec..efd1ff467d614 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -64,7 +64,7 @@ import { IChatRequestVariableEntry } from '../../common/attachments/chatVariable import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; import { getNWords } from '../../common/model/chatWordCounter.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; import { MarkHelpfulActionId, MarkUnhelpfulActionId } from '../actions/chatTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from '../chat.js'; @@ -2335,7 +2335,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - if (!shouldAutoReply) { + // always autoreply in autopilot mode. + const isAutopilot = isResponseVM(context.element) && context.element.model.request?.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot; + if (!shouldAutoReply && !isAutopilot) { // Roll back the in-progress mark if auto-reply is not enabled. if (stableKey) { this._autoRepliedQuestionCarousels.delete(stableKey); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 8a391fa71d69b..eb97584dd419b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -64,7 +64,7 @@ import { IChatTodoListService } from '../../common/tools/chatTodoListService.js' import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isWorkspaceVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../../common/constants.js'; import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../../common/promptSyntax/computeAutomaticInstructions.js'; import { IHandOff, PromptHeader } from '../../common/promptSyntax/promptFileParser.js'; @@ -1552,6 +1552,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.setPermissionLevel(this.inputPart.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); this.input.setEditing(true, isEditingSentRequest); this._onDidChangeActiveInputEditor.fire(); } else { @@ -1652,6 +1653,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!isInput) { this.inputPart.setChatMode(this.input.currentModeObs.get().id); + this.inputPart.setPermissionLevel(this.input.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); const currentModel = this.input.selectedLanguageModel.get(); if (currentModel) { this.inputPart.switchModel(currentModel.metadata); @@ -1735,6 +1737,7 @@ export class ChatWidget extends Disposable implements IChatWidget { defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, workspacePickerDelegate: this.viewOptions.workspacePickerDelegate, + isSessionsWindow: this.viewOptions.isSessionsWindow, }; if (this.viewModel?.editing) { @@ -2139,7 +2142,8 @@ export class ChatWidget extends Disposable implements IChatWidget { const options: IChatSendRequestOptions = { attempt: lastRequest.attempt + 1, location: this.location, - userSelectedModelId: this.input.currentLanguageModel + userSelectedModelId: this.input.currentLanguageModel, + modeInfo: this.input.currentModeInfo, }; return await this.chatService.resendRequest(lastRequest, options); } 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 8bd16f83c31d7..db90c77372dcd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -85,7 +85,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEnt import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatQuestionCarousel, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { agentOptionId, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, validateChatMode } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; @@ -95,7 +95,7 @@ import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chat import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; -import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; import { AgentSessionProviders, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; @@ -122,6 +122,7 @@ import { ChatSelectedTools } from './chatSelectedTools.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; +import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; @@ -169,6 +170,11 @@ export interface IChatInputPartOptions { * for their chat request. This is useful for empty window contexts. */ workspacePickerDelegate?: IWorkspacePickerDelegate; + /** + * Whether we are running in the sessions window. + * When true, the secondary toolbar (permissions picker) is hidden. + */ + isSessionsWindow?: boolean; } export interface IWorkingSetEntry { @@ -285,6 +291,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private container!: HTMLElement; private inputSideToolbarContainer?: HTMLElement; + private secondaryToolbarContainer!: HTMLElement; + private secondaryToolbar!: MenuWorkbenchToolBar; private followupsContainer!: HTMLElement; private readonly followupsDisposables: DisposableStore = this._register(new DisposableStore()); @@ -366,6 +374,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatSessionHasTargetedModels: IContextKey; private modelWidget: EnhancedModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; + private permissionWidget: PermissionPickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; private delegationWidget: DelegationSessionPickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); @@ -400,6 +409,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly onDidChangeCurrentChatMode: Event = this._onDidChangeCurrentChatMode.event; private readonly _currentModeObservable: ISettableObservable; + private readonly _currentPermissionLevel: ISettableObservable; + private permissionLevelKey: IContextKey; public get currentModeKind(): ChatModeKind { const mode = this._currentModeObservable.get(); @@ -412,6 +423,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._currentModeObservable; } + public get currentPermissionLevelObs(): IObservable { + return this._currentPermissionLevel; + } + public get currentModeInfo(): IChatRequestModeInfo { const mode = this._currentModeObservable.get(); const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = mode.isBuiltin ? this.currentModeKind : 'custom'; @@ -429,6 +444,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } : undefined, modeId: modeId, applyCodeBlockSuggestionId: undefined, + permissionLevel: this._currentPermissionLevel.get(), }; } @@ -529,6 +545,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); this._currentModeObservable = observableValue('currentMode', this.options.defaultMode ?? ChatMode.Agent); + this._currentPermissionLevel = observableValue('permissionLevel', ChatPermissionLevel.Default); this._register(this.editorService.onDidActiveEditorChange(() => { this._indexOfLastOpenedContext = -1; this.refreshChatSessionPickers(); @@ -580,6 +597,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatModeKindKey = ChatContextKeys.chatModeKind.bindTo(contextKeyService); this.chatModeNameKey = ChatContextKeys.chatModeName.bindTo(contextKeyService); this.chatModelIdKey = ChatContextKeys.chatModelId.bindTo(contextKeyService); + this.permissionLevelKey = ChatContextKeys.chatPermissionLevel.bindTo(contextKeyService); this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); @@ -790,6 +808,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.modeWidget?.show(); } + public openPermissionPicker(): void { + this.permissionWidget?.show(); + } + + public setPermissionLevel(level: ChatPermissionLevel): void { + this._currentPermissionLevel.set(level, undefined); + this.permissionLevelKey.set(level); + this.permissionWidget?.refresh(); + } + public openSessionTargetPicker(): void { this.sessionTargetWidget?.show(); } @@ -876,6 +904,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.selectedToolsModel.resetSessionEnablementState(); this._chatSessionIsEmpty = chatSessionIsEmpty; + // Reset permission level to default on new sessions + if (chatSessionIsEmpty) { + this._currentPermissionLevel.set(ChatPermissionLevel.Default, undefined); + this.permissionLevelKey.set(ChatPermissionLevel.Default); + this.permissionWidget?.refresh(); + } + // TODO@roblourens This is for an experiment which will be obsolete in a month or two and can then be removed. if (chatSessionIsEmpty) { this._setEmptyModelState(); @@ -1931,11 +1966,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ dom.h('.chat-input-container@inputContainer', [ dom.h('.chat-editor-container@editorContainer'), - dom.h('.chat-input-toolbars@inputToolbars', [ - dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), - ]), + dom.h('.chat-input-toolbars@inputToolbars'), ]), ]), + dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), + ]), dom.h('.chat-attachments-container@attachmentsContainer', [ dom.h('.chat-attached-context@attachedContextContainer'), ]), @@ -1956,11 +1992,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-attached-context@attachedContextContainer'), ]), dom.h('.chat-editor-container@editorContainer'), - dom.h('.chat-input-toolbars@inputToolbars', [ - dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), - ]), + dom.h('.chat-input-toolbars@inputToolbars'), ]), ]), + dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), + ]), ]); } this.container = elements.root; @@ -1981,6 +2018,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.attachmentsContainer = elements.attachmentsContainer; this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; + this.secondaryToolbarContainer = elements.secondaryToolbar; + if (this.options.isSessionsWindow) { + this.secondaryToolbarContainer.style.display = 'none'; + } this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; this.chatGettingStartedTipContainer = elements.chatGettingStartedTipContainer; @@ -1989,6 +2030,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer; this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; + if (this.options.isSessionsWindow) { + toolbarsContainer.prepend(this.contextUsageWidgetContainer); + } + // Context usage widget — will be positioned in the toolbar after toolbars are created this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); this.contextUsageWidgetContainer.appendChild(this.contextUsageWidget.domNode); @@ -2240,7 +2285,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // eslint-disable-next-line no-restricted-syntax const container = toolbarElement.querySelector('.chat-sessionPicker-container'); this.chatSessionPickerContainer = container as HTMLElement | undefined; - if (this.cachedWidth && typeof this.cachedInputToolbarWidth === 'number' && this.cachedInputToolbarWidth !== this.inputActionsToolbar.getItemsWidth()) { this._toolbarRelayoutScheduler.schedule(); } @@ -2273,6 +2317,62 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge toolbarSide.context = { widget } satisfies IChatExecuteActionContext; } + // Secondary toolbar (permissions) — below the input box + this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.secondaryToolbarContainer, MenuId.ChatInputSecondary, { + telemetrySource: this.options.menus.telemetrySource, + menuOptions: { shouldForwardArgs: true }, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + hoverDelegate, + actionViewItemProvider: (action, options) => { + if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { + const getActiveSessionType = () => { + const sessionResource = this._widget?.viewModel?.sessionResource; + return sessionResource ? getAgentSessionProvider(sessionResource) : undefined; + }; + const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { + getActiveSessionProvider: () => { + return getActiveSessionType(); + }, + getPendingDelegationTarget: () => { + return this._pendingDelegationTarget; + }, + setPendingDelegationTarget: (provider: AgentSessionProviders) => { + const isActive = getActiveSessionType() === provider; + this._pendingDelegationTarget = isActive ? undefined : provider; + this.updateWidgetLockStateFromSessionType(provider); + this.updateAgentSessionTypeContextKey(); + this.refreshChatSessionPickers(); + }, + }; + const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; + const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; + return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, pickerOptions); + } else if (action.id === OpenWorkspacePickerAction.ID && action instanceof MenuItemAction) { + if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && this.options.workspacePickerDelegate) { + return this.instantiationService.createInstance(WorkspacePickerActionItem, action, this.options.workspacePickerDelegate, pickerOptions); + } else { + const empty = new BaseActionViewItem(undefined, action); + if (empty.element) { + empty.element.style.display = 'none'; + } + return empty; + } + } else if (action.id === OpenPermissionPickerAction.ID && action instanceof MenuItemAction) { + const delegate: IPermissionPickerDelegate = { + currentPermissionLevel: this._currentPermissionLevel, + setPermissionLevel: (level: ChatPermissionLevel) => { + this._currentPermissionLevel.set(level, undefined); + this.permissionLevelKey.set(level); + }, + }; + return this.permissionWidget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, pickerOptions); + } + return undefined; + } + })); + this.secondaryToolbar.getElement().classList.add('chat-secondary-input-toolbar'); + this.secondaryToolbar.context = { widget } satisfies IChatExecuteActionContext; + let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { inputModel = this.modelService.createModel('', null, this.inputUri, true); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts new file mode 100644 index 0000000000000..5f0cc0988d2a3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; + +export interface IPermissionPickerDelegate { + readonly currentPermissionLevel: IObservable; + readonly setPermissionLevel: (level: ChatPermissionLevel) => void; +} + +export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { + constructor( + action: MenuItemAction, + private readonly delegate: IPermissionPickerDelegate, + pickerOptions: IChatInputPickerOptions, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService configurationService: IConfigurationService, + ) { + const isAutoApprovePolicyRestricted = () => configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + const isAutopilotEnabled = () => configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; + const actionProvider: IActionWidgetDropdownActionProvider = { + getActions: () => { + const currentLevel = delegate.currentPermissionLevel.get(); + const policyRestricted = isAutoApprovePolicyRestricted(); + const actions: IActionWidgetDropdownAction[] = [ + { + ...action, + id: 'chat.permissions.default', + label: localize('permissions.default', "Default Approvals"), + icon: ThemeIcon.fromId(Codicon.shield.id), + checked: currentLevel === ChatPermissionLevel.Default, + tooltip: '', + hover: { + content: localize('permissions.default.description', "Use configured approval settings"), + position: pickerOptions.hoverPosition + }, + run: async () => { + delegate.setPermissionLevel(ChatPermissionLevel.Default); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction, + { + ...action, + id: 'chat.permissions.autoApprove', + label: localize('permissions.autoApprove', "Bypass Approvals"), + icon: ThemeIcon.fromId(Codicon.warning.id), + checked: currentLevel === ChatPermissionLevel.AutoApprove, + enabled: !policyRestricted, + tooltip: policyRestricted ? localize('permissions.autoApprove.policyDisabled', "Disabled by enterprise policy") : '', + hover: { + content: policyRestricted + ? localize('permissions.autoApprove.policyDescription', "Disabled by enterprise policy") + : localize('permissions.autoApprove.description', "Auto-approve all tool calls and retry on errors"), + position: pickerOptions.hoverPosition + }, + run: async () => { + delegate.setPermissionLevel(ChatPermissionLevel.AutoApprove); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction, + ]; + if (isAutopilotEnabled()) { + actions.push({ + ...action, + id: 'chat.permissions.autopilot', + label: localize('permissions.autopilot', "Autopilot (Preview)"), + icon: ThemeIcon.fromId(Codicon.rocket.id), + checked: currentLevel === ChatPermissionLevel.Autopilot, + enabled: !policyRestricted, + tooltip: policyRestricted ? localize('permissions.autopilot.policyDisabled', "Disabled by enterprise policy") : '', + hover: { + content: policyRestricted + ? localize('permissions.autopilot.policyDescription', "Disabled by enterprise policy") + : localize('permissions.autopilot.description', "Auto-approve all tool calls and continue until the task is done"), + position: pickerOptions.hoverPosition + }, + run: async () => { + delegate.setPermissionLevel(ChatPermissionLevel.Autopilot); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction); + } + return actions; + } + }; + + super(action, { + actionProvider, + reporter: { id: 'ChatPermissionPicker', name: 'ChatPermissionPicker', includeOptions: true }, + }, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + this.setAriaLabelAttributes(element); + + const level = this.delegate.currentPermissionLevel.get(); + let icon: ThemeIcon; + let label: string; + switch (level) { + case ChatPermissionLevel.Autopilot: + icon = Codicon.rocket; + label = localize('permissions.autopilot.label', "Autopilot (Preview)"); + break; + case ChatPermissionLevel.AutoApprove: + icon = Codicon.warning; + label = localize('permissions.autoApprove.label', "Bypass Approvals"); + break; + default: + icon = Codicon.shield; + label = localize('permissions.default.label', "Default Approvals"); + break; + } + + const labelElements = []; + labelElements.push(...renderLabelWithIcons(`$(${icon.id})`)); + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); + + dom.reset(element, ...labelElements); + element.classList.toggle('warning', level === ChatPermissionLevel.Autopilot); + element.classList.toggle('info', level === ChatPermissionLevel.AutoApprove); + return null; + } + + public refresh(): void { + if (this.element) { + this.renderLabel(this.element); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 86c54b7bd96c3..08572fadc85c4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -822,11 +822,18 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; } -/* Context usage widget container - positioned in the bottom toolbar */ -.interactive-session .chat-input-toolbars .chat-context-usage-container { +/* Context usage widget container - positioned in the secondary toolbar below input */ +.interactive-session .chat-input-toolbars .chat-context-usage-container, +.interactive-session .chat-secondary-toolbar .chat-context-usage-container { display: flex; align-items: center; flex-shrink: 0; + margin-left: auto; + order: 1; +} + +/* When context usage is inside the toolbars (compact mode), keep the ordering */ +.interactive-session .chat-input-toolbars .chat-context-usage-container { order: 1; } @@ -1330,6 +1337,85 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-top: 4px; } +/* Secondary toolbar below the input box */ +.interactive-session .chat-secondary-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 5px 2px 6px; +} + +.interactive-session .chat-secondary-toolbar:empty { + display: none; +} + +.interactive-session .chat-secondary-toolbar > .chat-secondary-input-toolbar { + overflow: hidden; + min-width: 0px; + color: var(--vscode-icon-foreground); + + .monaco-action-bar .action-item .codicon { + color: var(--vscode-icon-foreground); + } + + .chat-input-picker-item { + min-width: 0px; + overflow: hidden; + + .action-label { + min-width: 0px; + overflow: hidden; + position: relative; + + .chat-input-picker-label { + overflow: hidden; + text-overflow: ellipsis; + } + + span + .chat-input-picker-label { + margin-left: 2px; + } + + .codicon { + font-size: 12px; + } + } + + .codicon { + flex-shrink: 0; + } + } +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label { + height: 16px; + padding: 3px 0px 3px 6px; + display: flex; + align-items: center; + color: var(--vscode-icon-foreground); +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.warning { + color: var(--vscode-problemsWarningIcon-foreground); +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.warning .codicon { + color: var(--vscode-problemsWarningIcon-foreground) !important; +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.info { + color: var(--vscode-problemsInfoIcon-foreground); +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.info .codicon { + color: var(--vscode-problemsInfoIcon-foreground) !important; +} + +.monaco-workbench .interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label .codicon-chevron-down { + font-size: 12px; + margin-left: 2px; +} + .interactive-session .chat-input-toolbars :not(.responsive.chat-input-toolbar) .actions-container:first-child { margin-right: auto; } @@ -1669,7 +1755,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part { margin: 0px 12px; - padding: 4px 0 12px 0px; + padding: 4px 0 6px 0px; display: flex; flex-direction: column; gap: 4px; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index b20f39c7cf678..e051fbcfb3f8e 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -92,6 +92,7 @@ export class ChatContextUsageWidget extends Disposable { readonly domNode: HTMLElement; private readonly progressIndicator: CircularProgressIndicator; + private readonly percentageLabel: HTMLElement; private readonly _isVisible = observableValue(this, false); get isVisible(): IObservable { return this._isVisible; } @@ -130,6 +131,9 @@ export class ChatContextUsageWidget extends Disposable { this.progressIndicator = new CircularProgressIndicator(); iconContainer.appendChild(this.progressIndicator.domNode); + // Percentage label (visible on hover/focus) + this.percentageLabel = this.domNode.appendChild($('.percentage-label')); + // Track context usage opened state this._contextUsageOpenedKey = ChatContextKeys.contextUsageHasBeenOpened.bindTo(this.contextKeyService); @@ -286,6 +290,11 @@ export class ChatContextUsageWidget extends Disposable { // Update pie chart progress this.progressIndicator.setProgress(percentage); + // Update percentage label and aria-label + const roundedPercentage = Math.round(percentage); + this.percentageLabel.textContent = `${roundedPercentage}%`; + this.domNode.setAttribute('aria-label', localize('contextUsagePercentageLabel', "Context window usage: {0}%", roundedPercentage)); + // Update color based on usage level this.domNode.classList.remove('warning', 'error'); if (percentage >= 90) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 905351e3adfc5..ab722bf9ad486 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -540,6 +540,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { supportsChangingModes: true, dndContainer: parent, inputEditorMinLines: this.workbenchEnvironmentService.isSessionsWindow ? 2 : undefined, + isSessionsWindow: this.workbenchEnvironmentService.isSessionsWindow, }, { listForeground: SIDE_BAR_FOREGROUND, diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css index c3abb1332f0de..e09c5e1aa2321 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css @@ -6,9 +6,7 @@ .chat-context-usage-widget { display: flex; align-items: center; - justify-content: center; - height: 22px; - width: 22px; + gap: 4px; flex-shrink: 0; cursor: pointer; padding: 3px; @@ -55,7 +53,7 @@ .chat-context-usage-widget .progress-arc { fill: none; - stroke: var(--vscode-descriptionForeground); + stroke: var(--vscode-icon-foreground); stroke-width: 4; stroke-linecap: round; transform: rotate(-90deg); @@ -70,3 +68,20 @@ .chat-context-usage-widget.error .progress-arc { stroke: var(--vscode-editorError-foreground); } + +.chat-context-usage-widget .percentage-label { + font-size: 11px; + line-height: 1; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + max-width: 0; + opacity: 0; + overflow: hidden; + transition: max-width 0.1s ease-out, opacity 0.1s ease-out; +} + +.chat-context-usage-widget:hover .percentage-label, +.chat-context-usage-widget:focus .percentage-label { + max-width: 4em; + opacity: 1; +} diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 926ba9d9f9754..cce18bceca5c9 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -9,7 +9,7 @@ import { IsWebContext } from '../../../../../platform/contextkey/common/contextk import { RemoteNameContext } from '../../../../common/contextkeys.js'; import { ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; -import { ChatAgentLocation, ChatModeKind } from '../constants.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; export namespace ChatContextKeys { export const responseVote = new RawContextKey('chatSessionResponseVote', '', { type: 'string', description: localize('interactiveSessionResponseVote', "When the response has been voted up, is set to 'up'. When voted down, is set to 'down'. Otherwise an empty string.") }); @@ -46,6 +46,7 @@ export namespace ChatContextKeys { export const multipleChatTips = new RawContextKey('multipleChatTips', false, { type: 'boolean', description: localize('multipleChatTips', "True when there are multiple chat tips available.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); + export const chatPermissionLevel = new RawContextKey('chatPermissionLevel', ChatPermissionLevel.Default, { type: 'string', description: localize('chatPermissionLevel', "The current permission level for tool auto-approval.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); export const chatModelId = new RawContextKey('chatModelId', '', { type: 'string', description: localize('chatModelId', "The short id of the currently selected chat model (for example 'gpt-4.1').") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0d1bb0635de2f..498a1ef40dafe 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1024,6 +1024,7 @@ export class ChatService extends Disposable implements IChatService { userSelectedModelId: options?.userSelectedModelId, userSelectedTools: options?.userSelectedTools?.get(), modeInstructions: options?.modeInfo?.modeInstructions, + permissionLevel: options?.modeInfo?.permissionLevel, editedFileEvents: request.editedFileEvents, hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), @@ -1180,6 +1181,7 @@ export class ChatService extends Disposable implements IChatService { shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); + if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { model.setFollowups(request!, followups); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 9ed80f0451e93..1cc89241a274f 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -54,6 +54,7 @@ export enum ChatConfiguration { ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', + AutopilotEnabled = 'chat.autopilot.enabled', } /** @@ -76,6 +77,26 @@ export function validateChatMode(mode: unknown): ChatModeKind | undefined { } } +/** + * The permission level controlling tool auto-approval behavior. + */ +export enum ChatPermissionLevel { + /** Use existing auto-approve settings */ + Default = 'default', + /** Auto-approve all tool calls, auto-retry on error */ + AutoApprove = 'autoApprove', + /** Everything AutoApprove does plus an internal stop hook that continues until the task is done */ + Autopilot = 'autopilot' +} + +/** + * Returns true if the permission level enables auto-approval of all tool calls. + * Both {@link ChatPermissionLevel.AutoApprove} and {@link ChatPermissionLevel.Autopilot} enable auto-approval. + */ +export function isAutoApproveLevel(level: ChatPermissionLevel | undefined): boolean { + return level === ChatPermissionLevel.AutoApprove || level === ChatPermissionLevel.Autopilot; +} + export function isChatMode(mode: unknown): mode is ChatModeKind { return !!validateChatMode(mode); } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 3fb3a88c82fb5..98098bb937681 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -30,7 +30,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo 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, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, 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 { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ToolDataSource, IToolData } from '../tools/languageModelToolsService.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; @@ -314,6 +314,7 @@ export interface IChatRequestModeInfo { modeInstructions: IChatRequestModeInstructions | undefined; modeId: 'ask' | 'agent' | 'edit' | 'custom' | 'applyCodeBlock' | undefined; applyCodeBlockSuggestionId: EditSuggestionId | undefined; + permissionLevel?: ChatPermissionLevel; } export interface IChatRequestModeInstructions { diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 817ebbb18b820..e8f4fbe4a03bc 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -24,7 +24,7 @@ import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRe import { ChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; //#region agent service, commands etc @@ -158,6 +158,12 @@ export interface IChatAgentRequest { * Whether any hooks are enabled for this request. */ hasHooksEnabled?: boolean; + /** + * The permission level for tool auto-approval in this request. + * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. + * - `'autopilot'`: Everything autoApprove does plus continues until the task is done. + */ + permissionLevel?: ChatPermissionLevel; /** * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index 5cca23e27618e..466ff653c3ed4 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -13,6 +13,7 @@ import { localize } from '../../../../../../nls.js'; import { IChatQuestion, IChatService } from '../../chatService/chatService.js'; import { ChatQuestionCarouselData } from '../../model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatRequestModel } from '../../model/chatModel.js'; +import { ChatPermissionLevel } from '../../constants.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -22,6 +23,12 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { raceCancellation } from '../../../../../../base/common/async.js'; import { URI } from '../../../../../../base/common/uri.js'; +/** + * Response returned to the model when the user is not available (autopilot mode). + */ +export const AUTOPILOT_ASK_USER_RESPONSE = + 'The user is not available to respond and will review your work later. Work autonomously and make good decisions.'; + // Use a distinct id to avoid clashing with extension-provided tools export const AskQuestionsToolId = 'vscode_askQuestions'; @@ -203,6 +210,17 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return this.createSkippedResult(questions); } + // In autopilot mode, the user is not available — auto-respond instead of blocking. + // Still append a completed carousel so the user can see the auto-selected answers. + if (request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot) { + this.logService.info('[AskQuestionsTool] Autopilot mode: auto-responding to questions'); + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); + carousel.data = this.buildAutopilotCarouselAnswers(questions, carousel, idToHeaderMap); + carousel.isUsed = true; + this.chatService.appendProgress(request, carousel); + return this.createAutopilotResult(questions); + } + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); this.chatService.appendProgress(request, carousel); @@ -494,6 +512,63 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { }; } + private createAutopilotResult(questions: IQuestion[]): IToolResult { + const answers: Record = {}; + for (const question of questions) { + // Pick the recommended option if available, otherwise pick the first option + const recommended = question.options?.find(opt => opt.recommended); + const firstOption = question.options?.[0]; + const selected = recommended?.label ?? firstOption?.label; + answers[question.header] = { + selected: selected ? [selected] : [], + freeText: selected ? null : AUTOPILOT_ASK_USER_RESPONSE, + skipped: false, + }; + } + return { + content: [{ kind: 'text', value: JSON.stringify({ answers } satisfies IAnswerResult) }] + }; + } + + /** + * Build carousel answer data keyed by carousel question IDs for rendering + * the completed summary in the UI during autopilot mode. + */ + private buildAutopilotCarouselAnswers(questions: IQuestion[], carousel: ChatQuestionCarouselData, idToHeaderMap: Map): Record { + const data: Record = {}; + // Build reverse map: original header -> internal carousel question ID + const headerToIdMap = new Map(); + for (const [internalId, originalHeader] of idToHeaderMap) { + headerToIdMap.set(originalHeader, internalId); + } + + for (const question of questions) { + const internalId = headerToIdMap.get(question.header); + if (!internalId) { + continue; + } + + const chatQuestion = carousel.questions.find(q => q.id === internalId); + if (!chatQuestion) { + continue; + } + + const recommended = question.options?.find(opt => opt.recommended); + const firstOption = question.options?.[0]; + const selectedLabel = recommended?.label ?? firstOption?.label; + + if (chatQuestion.type === 'text' || !selectedLabel) { + data[internalId] = AUTOPILOT_ASK_USER_RESPONSE; + } else if (chatQuestion.type === 'multiSelect') { + data[internalId] = { selectedValues: [selectedLabel] }; + } else { + data[internalId] = { selectedValue: selectedLabel }; + } + } + + return data; + } + private sendTelemetry(requestId: string | undefined, questionCount: number, answeredCount: number, skippedCount: number, freeTextCount: number, recommendedAvailableCount: number, recommendedSelectedCount: number, duration: number): void { this.telemetryService.publicLog2('askQuestionsToolInvoked', { requestId, diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts new file mode 100644 index 0000000000000..74879d30d2111 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress, CountTokensCallback } from '../languageModelToolsService.js'; + +export const TaskCompleteToolId = 'task_complete'; + +/** + * Message sent to the agent when the session goes idle without task completion. + */ +export const AUTOPILOT_CONTINUATION_MESSAGE = + 'You have not yet marked the task as complete using the task_complete tool. ' + + 'You MUST call task_complete when done — whether the task involved code changes, answering a question, or any other interaction.\n\n' + + 'Do NOT repeat or restate your previous response. Pick up where you left off.\n\n' + + 'If you were planning, stop planning and start implementing. ' + + 'You are not done until you have fully completed the task.\n\n' + + 'IMPORTANT: Do NOT call task_complete if:\n' + + '- You have open questions or ambiguities — make good decisions and keep working\n' + + '- You encountered an error — try to resolve it or find an alternative approach\n' + + '- There are remaining steps — complete them first\n\n' + + 'Keep working autonomously until the task is truly finished, then call task_complete.'; + +export const TaskCompleteToolData: IToolData = { + id: TaskCompleteToolId, + displayName: 'Task Complete', + modelDescription: + 'Signal that the user\'s task is fully done. You MUST call this tool when your work is complete — ' + + 'whether you made code changes, answered a question, or completed any other kind of task. ' + + 'Provide a brief summary of what was accomplished. If the summary is trivial (e.g. answering a question), omit it. ' + + 'Do not restate the summary in your message text — it is shown to the user directly.\n\n' + + 'When to call:\n' + + '- After answering the user\'s question or completing a conversational request\n' + + '- After you have completed ALL requested changes\n' + + '- After verifying results: tests pass, terminal commands succeeded, tool calls returned expected output\n\n' + + 'When NOT to call:\n' + + '- If a terminal command failed or produced unexpected output\n' + + '- If an MCP or external tool call returned an error\n' + + '- If you encountered errors you have not resolved\n' + + '- If there are remaining steps to complete\n' + + '- If you have not verified your changes work', + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + summary: { + type: 'string', + description: 'Brief summary of what was accomplished. Omit for trivial interactions.', + }, + }, + }, +}; + +export class TaskCompleteTool implements IToolImpl { + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + presentation: ToolInvocationPresentation.Hidden, + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as { summary?: string }; + const summary = params?.summary ?? 'All done!'; + return { + content: [{ + kind: 'text', + value: summary, + }], + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts index 619e63406dd36..74f61e8b613b1 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -13,6 +13,7 @@ import { EditTool, EditToolData } from './editFileTool.js'; import { createManageTodoListToolData, ManageTodoListTool } from './manageTodoListTool.js'; import { ResolveDebugEventDetailsTool, ResolveDebugEventDetailsToolData } from './resolveDebugEventDetailsTool.js'; import { RunSubagentTool } from './runSubagentTool.js'; +import { TaskCompleteTool, TaskCompleteToolData } from './taskCompleteTool.js'; export class BuiltinToolsContribution extends Disposable implements IWorkbenchContribution { @@ -35,15 +36,19 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo const manageTodoListTool = this._register(instantiationService.createInstance(ManageTodoListTool)); this._register(toolsService.registerTool(todoToolData, manageTodoListTool)); - // Register the confirmation tool const confirmationTool = instantiationService.createInstance(ConfirmationTool); this._register(toolsService.registerTool(ConfirmationToolData, confirmationTool)); this._register(toolsService.registerTool(ConfirmationToolWithOptionsData, confirmationTool)); + + const taskCompleteTool = instantiationService.createInstance(TaskCompleteTool); + this._register(toolsService.registerTool(TaskCompleteToolData, taskCompleteTool)); + const resolveDebugEventDetailsTool = instantiationService.createInstance(ResolveDebugEventDetailsTool); this._register(toolsService.registerTool(ResolveDebugEventDetailsToolData, resolveDebugEventDetailsTool)); this._register(toolsService.readToolSet.addTool(ResolveDebugEventDetailsToolData)); + const runSubagentTool = this._register(instantiationService.createInstance(RunSubagentTool)); let runSubagentRegistration: IDisposable | undefined; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 16c8ff488a027..839499ef55f04 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 14 +// version: 15 declare module 'vscode' { @@ -116,6 +116,13 @@ declare module 'vscode' { */ readonly parentRequestId?: string; + /** + * The permission level for tool auto-approval in this request. + * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. + * - `'autopilot'`: Everything autoApprove does plus continues until the task is done. + */ + readonly permissionLevel?: string; + /** * Whether any hooks are enabled for this request. */