Path: blob/main/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import '../media/agentHostSessionConfigPicker.css';6import * as dom from '../../../../../base/browser/dom.js';7import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js';8import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';9import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js';10import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';11import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js';12import { Delayer } from '../../../../../base/common/async.js';13import { Codicon } from '../../../../../base/common/codicons.js';14import { MarkdownString } from '../../../../../base/common/htmlContent.js';15import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';16import { autorun, observableValue } from '../../../../../base/common/observable.js';17import Severity from '../../../../../base/common/severity.js';18import { ThemeIcon } from '../../../../../base/common/themables.js';19import { localize, localize2 } from '../../../../../nls.js';20import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../platform/actions/browser/actionViewItemService.js';21import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js';22import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';23import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';24import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';25import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';26import type { SessionConfigPropertySchema, SessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js';27import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js';28import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';29import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';30import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';31import { Menus } from '../../../../browser/menus.js';32import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.js';33import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';34import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';35import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';36import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js';37import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js';38import { AgentHostModePicker } from './agentHostModePicker.js';39import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js';40import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js';41import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js';4243const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE);44const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID);4546registerAction2(class extends Action2 {47constructor() {48super({49id: 'sessions.agentHost.sessionConfigPicker',50title: localize2('agentHostSessionConfigPicker', "Session Configuration"),51f1: false,52menu: [{53id: Menus.NewSessionRepositoryConfig,54group: 'navigation',55order: 3,56when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost),57}],58});59}6061override async run(): Promise<void> { }62});6364interface IConfigPickerItem {65readonly value: string;66readonly label: string;67readonly description?: string;68}6970function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon | undefined {71if (property === 'isolation') {72if (value === 'folder') {73return Codicon.folder;74}75if (value === 'worktree') {76return Codicon.worktree;77}78}79if (property === 'branch') {80return Codicon.gitBranch;81}82if (property === 'autoApprove') {83if (value === 'autopilot') {84return Codicon.rocket;85}86if (value === 'autoApprove') {87return Codicon.warning;88}89return Codicon.shield;90}91return undefined;92}9394function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: unknown | undefined, policyRestricted?: boolean): IActionListItem<IConfigPickerItem>[] {95return items.map(item => ({96kind: ActionListItemKind.Action,97label: item.label,98description: item.description,99group: { title: '', icon: getConfigIcon(property, item.value) },100disabled: policyRestricted && (item.value === 'autoApprove' || item.value === 'autopilot'),101item: { ...item, label: item.value === currentValue ? `${item.label} ${localize('selected', "(Selected)")}` : item.label },102}));103}104105function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables: DisposableStore, onOpen: () => void): HTMLElement {106const trigger = dom.append(slot, disabled ? dom.$('span.action-label') : dom.$('a.action-label'));107if (disabled) {108trigger.setAttribute('aria-readonly', 'true');109} else {110trigger.role = 'button';111trigger.tabIndex = 0;112trigger.setAttribute('aria-haspopup', 'listbox');113disposables.add(Gesture.addTarget(trigger));114for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) {115disposables.add(dom.addDisposableListener(trigger, eventType, e => {116dom.EventHelper.stop(e, true);117onOpen();118}));119}120disposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, e => {121if (e.key === 'Enter' || e.key === ' ') {122dom.EventHelper.stop(e, true);123onOpen();124}125}));126}127slot.classList.toggle('disabled', disabled);128129return trigger;130}131132// Track whether auto-approve warnings have been shown this VS Code session133const shownAutoApproveWarnings = new Set<string /* enum value */>();134135function hasShownAutoApproveWarning(value: string): boolean {136if (shownAutoApproveWarnings.has(value)) {137return true;138}139// Confirming Autopilot implies the user accepted the Bypass risks too140if (value === 'autoApprove' && shownAutoApproveWarnings.has('autopilot')) {141return true;142}143return false;144}145146/**147* Filters out autopilot if disabled, and marks bypass/autopilot as disabled148* if enterprise policy restricts auto-approval. Returns the filtered items149* and policy state.150*/151function applyAutoApproveFiltering(152items: readonly IConfigPickerItem[],153property: string,154configurationService: IConfigurationService,155): { readonly items: readonly IConfigPickerItem[]; readonly policyRestricted: boolean } {156if (property !== SessionConfigKey.AutoApprove) {157return { items, policyRestricted: false };158}159const isAutopilotEnabled = configurationService.getValue<boolean>(ChatConfiguration.AutopilotEnabled) !== false;160const policyRestricted = configurationService.inspect<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;161const filtered = isAutopilotEnabled ? items : items.filter(item => item.value !== 'autopilot');162return { items: filtered, policyRestricted };163}164165/**166* Shows a confirmation dialog for elevated auto-approve levels.167* Returns true if confirmed or if the warning was already shown this session.168*/169async function confirmAutoApproveLevel(value: string, dialogService: IDialogService): Promise<boolean> {170if (hasShownAutoApproveWarning(value)) {171return true;172}173174const isAutopilot = value === 'autopilot';175const result = await dialogService.prompt({176type: Severity.Warning,177message: isAutopilot178? localize('agentHostAutoApprove.autopilot.warning.title', "Enable Autopilot?")179: localize('agentHostAutoApprove.bypass.warning.title', "Enable Bypass Approvals?"),180buttons: [181{182label: localize('agentHostAutoApprove.warning.confirm', "Enable"),183run: () => true,184},185{186label: localize('agentHostAutoApprove.warning.cancel', "Cancel"),187run: () => false,188},189],190custom: {191icon: isAutopilot ? Codicon.rocket : Codicon.warning,192markdownDetails: [{193markdown: new MarkdownString(194localize(195'agentHostAutoApprove.warning.detailWithDefaultSetting',196"{0}\n\nTo make this the starting permission level for new chat sessions, change the [{1}](command:workbench.action.openSettings?%5B%22{1}%22%5D) setting.",197isAutopilot198? localize('agentHostAutoApprove.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. This includes terminal commands, file edits, and external tool calls. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.")199: localize('agentHostAutoApprove.bypass.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls."),200ChatConfiguration.DefaultPermissionLevel,201),202{ isTrusted: { enabledCommands: ['workbench.action.openSettings'] } },203),204}],205},206});207208if (result.result !== true) {209return false;210}211212shownAutoApproveWarnings.add(value);213return true;214}215216/**217* Applies warning/info CSS classes to a trigger element for auto-approve levels.218*/219function applyAutoApproveTriggerStyles(trigger: HTMLElement, property: string | undefined, value: unknown | undefined): void {220if (property === SessionConfigKey.AutoApprove) {221trigger.classList.toggle('warning', value === 'autopilot');222trigger.classList.toggle('info', value === 'autoApprove');223}224}225226class AgentHostSessionConfigPicker extends Disposable {227228private readonly _renderDisposables = this._register(new DisposableStore());229private readonly _providerListeners = this._register(new DisposableMap<string>());230private readonly _filterDelayer = this._register(new Delayer<readonly IActionListItem<IConfigPickerItem>[]>(200));231private _container: HTMLElement | undefined;232233constructor(234@IActionWidgetService private readonly _actionWidgetService: IActionWidgetService,235@IConfigurationService private readonly _configurationService: IConfigurationService,236@IDialogService private readonly _dialogService: IDialogService,237@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,238@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,239) {240super();241242this._register(autorun(reader => {243const session = this._sessionsManagementService.activeSession.read(reader);244if (session) {245session.loading.read(reader);246}247this._renderConfigPickers();248}));249250this._register(this._sessionsProvidersService.onDidChangeProviders(e => {251for (const provider of e.removed) {252this._providerListeners.deleteAndDispose(provider.id);253}254this._watchProviders(e.added);255this._renderConfigPickers();256}));257this._watchProviders(this._sessionsProvidersService.getProviders());258}259260private _watchProviders(providers: readonly ISessionsProvider[]): void {261for (const provider of providers) {262if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) {263continue;264}265this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._renderConfigPickers()));266}267}268269render(container: HTMLElement): void {270this._container = dom.append(container, dom.$('.sessions-chat-agent-host-config'));271this._renderConfigPickers();272}273274private _renderConfigPickers(): void {275if (!this._container) {276return;277}278279this._renderDisposables.clear();280dom.clearNode(this._container);281282const session = this._sessionsManagementService.activeSession.get();283const provider = session ? this._getProvider(session.providerId) : undefined;284const resolvedConfig = session && provider?.getSessionConfig(session.sessionId);285if (!session || !provider || !resolvedConfig) {286return;287}288289// In the running-session flow only `sessionMutable` properties can290// actually be changed (non-mutable ones would no-op in291// `setSessionConfigValue`). In the new-session flow any property is292// changeable because changes trigger a full config re-resolve — so293// non-mutable properties like `isolation` must remain visible and294// interactive there.295const isNewSession = provider.getCreateSessionConfig(session.sessionId) !== undefined;296297for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) {298if (property === SessionConfigKey.BranchNameHint) {299continue;300}301// Only render pickers for properties we know how to present. Today302// that's string properties with an `enum` — anything else (objects,303// arrays, free-form strings, numbers, booleans) has no enumerable304// choice set and is edited through the JSONC settings editor instead.305if (schema.type !== 'string' || !schema.enum || schema.enum.length === 0) {306continue;307}308// In a running session, skip non-mutable properties — they can't309// be changed and would render as dead pills.310if (!isNewSession && !schema.sessionMutable) {311continue;312}313// When the autoApprove property uses the well-known schema, the314// workbench `PermissionPickerActionItem` (registered separately for315// `Menus.NewSessionControl`) handles it — skip it here to avoid316// double-rendering. Non-conforming schemas still fall through to317// the generic per-property picker below.318if (property === SessionConfigKey.AutoApprove && isWellKnownAutoApproveSchema(schema)) {319continue;320}321// When the mode property uses the well-known schema, the dedicated322// {@link AgentHostModePicker} (registered separately for323// `Menus.NewSessionConfig`) handles it. Non-conforming schemas324// still fall through to the generic per-property picker below.325if (property === SessionConfigKey.Mode && isWellKnownModeSchema(schema)) {326continue;327}328const value = resolvedConfig.values[property] ?? schema.default;329const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot'));330const trigger = renderPickerTrigger(slot, !!schema.readOnly, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger));331this._renderTrigger(trigger, property, schema, value);332}333}334335private _renderTrigger(trigger: HTMLElement, property: string, schema: SessionConfigPropertySchema, value: unknown | undefined): void {336dom.clearNode(trigger);337const icon = getConfigIcon(property, value);338if (icon) {339dom.append(trigger, renderIcon(icon));340}341const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label'));342const label = this._getLabel(schema, value);343labelSpan.textContent = label;344trigger.setAttribute('aria-label', schema.readOnly345? localize('agentHostSessionConfig.triggerAriaReadOnly', "{0}: {1}, Read-Only", schema.title, label)346: localize('agentHostSessionConfig.triggerAria', "{0}: {1}", schema.title, label));347if (!schema.readOnly) {348dom.append(trigger, renderIcon(Codicon.chevronDown));349}350applyAutoApproveTriggerStyles(trigger, property, value);351}352353private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, trigger: HTMLElement): Promise<void> {354if (schema.readOnly || this._actionWidgetService.isVisible) {355return;356}357const rawItems = await this._getItems(provider, sessionId, property, schema);358const { items, policyRestricted } = applyAutoApproveFiltering(rawItems, property, this._configurationService);359if (items.length === 0) {360return;361}362363const isAutoApproveProperty = property === SessionConfigKey.AutoApprove;364const currentValue = provider.getSessionConfig(sessionId)?.values[property];365const actionItems = toActionItems(property, items, currentValue, policyRestricted);366367const delegate: IActionListDelegate<IConfigPickerItem> = {368onSelect: async item => {369this._actionWidgetService.hide();370371if (isAutoApproveProperty && (item.value === 'autoApprove' || item.value === 'autopilot')) {372const confirmed = await confirmAutoApproveLevel(item.value, this._dialogService);373if (!confirmed) {374return;375}376}377378provider.setSessionConfigValue(sessionId, property, item.value).catch(() => { /* best-effort */ });379},380onFilter: schema.enumDynamic381? query => this._filterDelayer.trigger(async () => toActionItems(property, await this._getItems(provider, sessionId, property, schema, query), provider.getSessionConfig(sessionId)?.values[property]))382: undefined,383onHide: () => trigger.focus(),384};385386this._actionWidgetService.show<IConfigPickerItem>(387`agentHostSessionConfig.${property}`,388false,389actionItems,390delegate,391trigger,392undefined,393[],394{395getAriaLabel: item => item.label ?? '',396getWidgetAriaLabel: () => localize('agentHostSessionConfig.ariaLabel', "{0} Picker", schema.title),397},398actionItems.length > 10 ? { showFilter: true, filterPlaceholder: localize('agentHostSessionConfig.filter', "Filter options...") } : undefined,399);400}401402private async _getItems(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, query?: string): Promise<readonly IConfigPickerItem[]> {403const dynamicItems = schema.enumDynamic404? await provider.getSessionConfigCompletions(sessionId, property, query)405: undefined;406if (dynamicItems?.length) {407return dynamicItems.map(item => this._fromCompletionItem(item));408}409410return (schema.enum ?? []).map((value, index) => ({411value,412label: schema.enumLabels?.[index] ?? value,413description: schema.enumDescriptions?.[index],414}));415}416417private _fromCompletionItem(item: SessionConfigValueItem): IConfigPickerItem {418return {419value: item.value,420label: item.label,421description: item.description,422};423}424425private _getLabel(schema: SessionConfigPropertySchema, value: unknown | undefined): string {426if (typeof value === 'string') {427const index = schema.enum?.indexOf(value) ?? -1;428return index >= 0 ? schema.enumLabels?.[index] ?? value : value;429}430return schema.title;431}432433private _getProvider(providerId: string): IAgentHostSessionsProvider | undefined {434const provider = this._sessionsProvidersService.getProvider(providerId);435return provider && isAgentHostProvider(provider) ? provider : undefined;436}437}438439interface IConfigPickerWidget extends IDisposable {440render(container: HTMLElement): void;441}442443class PickerActionViewItem extends BaseActionViewItem {444constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) {445super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } });446if (disposable) {447this._register(disposable);448}449}450451override render(container: HTMLElement): void {452this._picker.render(container);453}454455override dispose(): void {456this._picker.dispose();457super.dispose();458}459}460461class AgentHostSessionConfigPickerContribution extends Disposable implements IWorkbenchContribution {462static readonly ID = 'sessions.contrib.agentHostSessionConfigPicker';463464constructor(465@IActionViewItemService actionViewItemService: IActionViewItemService,466@IInstantiationService private readonly _instantiationService: IInstantiationService,467) {468super();469this._register(actionViewItemService.register(470Menus.NewSessionRepositoryConfig,471'sessions.agentHost.sessionConfigPicker',472() => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostSessionConfigPicker)),473));474this._register(actionViewItemService.register(475Menus.NewSessionConfig,476NEW_SESSION_MODE_PICKER_ID,477() => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostModePicker)),478));479this._register(actionViewItemService.register(480MenuId.ChatInput,481RUNNING_SESSION_MODE_PICKER_ID,482() => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostModePicker)),483));484this._register(actionViewItemService.register(485Menus.NewSessionControl,486NEW_SESSION_APPROVE_PICKER_ID,487() => this._createNewSessionPermissionPicker(),488));489this._register(actionViewItemService.register(490MenuId.ChatInputSecondary,491RUNNING_SESSION_CONFIG_PICKER_ID,492this._createRunningSessionPermissionPickerFactory(),493));494}495496/**497* On the new-chat page (left of the toolbar), use the sessions498* {@link PermissionPicker} so the styling matches the surrounding sessions499* pickers (font size, padding, icon size).500*/501private _createNewSessionPermissionPicker(): PickerActionViewItem {502const delegate = this._instantiationService.createInstance(AgentHostPermissionPickerDelegate);503const picker = this._instantiationService.createInstance(PermissionPicker, delegate);504return new PickerActionViewItem(picker, delegate);505}506507/**508* Inside a running chat widget (`ChatInputSecondary`), use the workbench509* {@link PermissionPickerActionItem} so it matches the rest of the510* chat-input secondary toolbar (which is what the extension-host CLI511* already uses).512*/513private _createRunningSessionPermissionPickerFactory(): IActionViewItemFactory {514return (action, _options, instantiationService) => {515if (!(action instanceof MenuItemAction)) {516return undefined;517}518const pickerOptions: IChatInputPickerOptions = {519hideChevrons: observableValue('hideChevrons', false),520};521return instantiationService.createInstance(522AgentHostPermissionPickerActionItem,523action,524pickerOptions,525);526};527}528}529530// ---- New session auto-approve picker (left side, NewSessionControl) ----531532const NEW_SESSION_APPROVE_PICKER_ID = 'sessions.agentHost.newSessionApprovePicker';533534registerAction2(class extends Action2 {535constructor() {536super({537id: NEW_SESSION_APPROVE_PICKER_ID,538title: localize2('agentHostNewSessionApprovePicker', "Session Approvals"),539f1: false,540menu: [{541id: Menus.NewSessionControl,542group: 'navigation',543order: 1,544when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost),545}],546});547}548549override async run(): Promise<void> { }550});551552553// ---- New session mode picker (NewSessionConfig) ----554555const NEW_SESSION_MODE_PICKER_ID = 'sessions.agentHost.newSessionModePicker';556557registerAction2(class extends Action2 {558constructor() {559super({560id: NEW_SESSION_MODE_PICKER_ID,561title: localize2('agentHostNewSessionModePicker', "Agent Mode"),562f1: false,563menu: [{564id: Menus.NewSessionConfig,565group: 'navigation',566order: 0,567when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost),568}],569});570}571572override async run(): Promise<void> { }573});574575576// ---- Running session config picker (ChatInputSecondary) ----577578const RUNNING_SESSION_CONFIG_PICKER_ID = 'sessions.agentHost.runningSessionConfigPicker';579580registerAction2(class extends Action2 {581constructor() {582super({583id: RUNNING_SESSION_CONFIG_PICKER_ID,584title: localize2('agentHostRunningSessionConfigPicker', "Session Approvals"),585f1: false,586menu: [{587id: MenuId.ChatInputSecondary,588group: 'navigation',589order: 10,590when: ChatContextKeyExprs.isAgentHostSession,591}],592});593}594595override async run(): Promise<void> { }596});597598599// ---- Running session mode picker (ChatInput, beside the model picker) ----600601const RUNNING_SESSION_MODE_PICKER_ID = 'sessions.agentHost.runningSessionModePicker';602603registerAction2(class extends Action2 {604constructor() {605super({606id: RUNNING_SESSION_MODE_PICKER_ID,607title: localize2('agentHostRunningSessionModePicker', "Agent Mode"),608f1: false,609menu: [{610id: MenuId.ChatInput,611group: 'navigation',612// `OpenModelPickerAction` (the "Auto" model picker) is at order 3613// in the same menu — sit just before it so the mode pill renders614// to the left of "Pick Model".615order: 2,616when: ChatContextKeyExprs.isAgentHostSession,617}],618});619}620621override async run(): Promise<void> { }622});623624625registerWorkbenchContribution2(AgentHostSessionConfigPickerContribution.ID, AgentHostSessionConfigPickerContribution, WorkbenchPhase.AfterRestored);626627628