Merge pull request #265683 from microsoft/copilot/add-terminal-profile-for-agent

Add terminal profile configuration for chat agent tools
This commit is contained in:
Daniel Imms
2025-09-28 12:56:05 +09:00
committed by GitHub
5 changed files with 181 additions and 26 deletions
@@ -33,7 +33,7 @@ export const terminalIconSchema: IJSONSchema = {
markdownEnumDescriptions: Array.from(getAllCodicons(), icon => `$(${icon.id})`),
};
const terminalProfileBaseProperties: IJSONSchemaMap = {
export const terminalProfileBaseProperties: IJSONSchemaMap = {
args: {
description: localize('terminalProfile.args', 'An optional set of arguments to run the shell executable with.'),
type: 'array',
@@ -41,10 +41,6 @@ const terminalProfileBaseProperties: IJSONSchemaMap = {
type: 'string'
}
},
overrideName: {
description: localize('terminalProfile.overrideName', 'Whether or not to replace the dynamic terminal title that detects what program is running with the static profile name.'),
type: 'boolean'
},
icon: {
description: localize('terminalProfile.icon', 'A codicon ID to associate with the terminal icon.'),
...terminalIconSchema
@@ -74,6 +70,10 @@ const terminalProfileSchema: IJSONSchema = {
type: 'string'
}
},
overrideName: {
description: localize('terminalProfile.overrideName', 'Whether or not to replace the dynamic terminal title that detects what program is running with the static profile name.'),
type: 'boolean'
},
...terminalProfileBaseProperties
}
};
@@ -13,7 +13,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js';
import { PromptInputState } from '../../../../../platform/terminal/common/capabilities/commandDetection/promptInputModel.js';
import { ITerminalLogService, TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js';
import { ITerminalLogService, ITerminalProfile, TerminalSettingId, type IShellLaunchConfig } from '../../../../../platform/terminal/common/terminal.js';
import { ITerminalService, type ITerminalInstance } from '../../../terminal/browser/terminal.js';
import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js';
@@ -49,8 +49,8 @@ export class ToolTerminalCreator {
) {
}
async createTerminal(shell: string, token: CancellationToken): Promise<IToolTerminal> {
const instance = await this._createCopilotTerminal(shell);
async createTerminal(shellOrProfile: string | ITerminalProfile, token: CancellationToken): Promise<IToolTerminal> {
const instance = await this._createCopilotTerminal(shellOrProfile);
const toolTerminal: IToolTerminal = {
instance,
shellIntegrationQuality: ShellIntegrationQuality.None,
@@ -126,17 +126,30 @@ export class ToolTerminalCreator {
}
}
private _createCopilotTerminal(shell: string) {
return this._terminalService.createTerminal({
config: {
executable: shell,
icon: ThemeIcon.fromId(Codicon.chatSparkle.id),
hideFromUser: true,
env: {
GIT_PAGER: 'cat', // avoid making `git diff` interactive when called from copilot
},
},
});
private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile) {
const config: IShellLaunchConfig = {
icon: ThemeIcon.fromId(Codicon.chatSparkle.id),
hideFromUser: true,
env: {
// Avoid making `git diff` interactive when called from copilot
GIT_PAGER: 'cat',
}
};
if (typeof shellOrProfile === 'string') {
config.executable = shellOrProfile;
} else {
config.executable = shellOrProfile.path;
config.args = shellOrProfile.args;
config.icon = shellOrProfile.icon ?? config.icon;
config.color = shellOrProfile.color;
config.env = {
...config.env,
...shellOrProfile.env
};
}
return this._terminalService.createTerminal({ config });
}
private _waitForShellIntegration(
@@ -22,7 +22,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js';
import { ITerminalLogService, ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js';
import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js';
import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import { openTerminalSettingsLinkCommandId } from '../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.js';
@@ -616,22 +616,74 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
// #region Terminal init
private async _getCopilotShell(): Promise<string> {
protected async _getCopilotShellOrProfile(): Promise<string | ITerminalProfile> {
const os = await this._osBackend;
// Check for chat agent terminal profile first
const customChatAgentProfile = this._getChatTerminalProfile(os);
if (customChatAgentProfile) {
return customChatAgentProfile;
}
// When setting is null, use the previous behavior
const defaultShell = await this._terminalProfileResolverService.getDefaultShell({
os: await this._osBackend,
os,
remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority
});
// Force pwsh over cmd as cmd doesn't have shell integration
if (basename(defaultShell) === 'cmd.exe') {
return 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
}
return defaultShell;
}
private async _getCopilotShell(): Promise<string> {
const shellOrProfile = await this._getCopilotShellOrProfile();
if (typeof shellOrProfile === 'string') {
return shellOrProfile;
}
return shellOrProfile.path;
}
private _getChatTerminalProfile(os: OperatingSystem): ITerminalProfile | undefined {
let profileSetting: string;
switch (os) {
case OperatingSystem.Windows:
profileSetting = TerminalChatAgentToolsSettingId.TerminalProfileWindows;
break;
case OperatingSystem.Macintosh:
profileSetting = TerminalChatAgentToolsSettingId.TerminalProfileMacOs;
break;
case OperatingSystem.Linux:
default:
profileSetting = TerminalChatAgentToolsSettingId.TerminalProfileLinux;
break;
}
const profile = this._configurationService.getValue(profileSetting);
if (this._isValidChatAgentTerminalProfile(profile)) {
return profile;
}
return undefined;
}
private _isValidChatAgentTerminalProfile(profile: unknown): profile is ITerminalProfile {
if (profile === null || profile === undefined || typeof profile !== 'object') {
return false;
}
if ('path' in profile && typeof (profile as { path: unknown }).path === 'string') {
return true;
}
return false;
}
private async _initBackgroundTerminal(chatSessionId: string, termId: string, token: CancellationToken): Promise<IToolTerminal> {
this._logService.debug(`RunInTerminalTool: Creating background terminal with ID=${termId}`);
const shell = await this._getCopilotShell();
const toolTerminal = await this._terminalToolCreator.createTerminal(shell, token);
const shellOrProfile = await this._getCopilotShellOrProfile();
const toolTerminal = await this._terminalToolCreator.createTerminal(shellOrProfile, token);
this._registerInputListener(toolTerminal);
this._sessionTerminalAssociations.set(chatSessionId, toolTerminal);
if (token.isCancellationRequested) {
@@ -649,8 +701,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
this._terminalToolCreator.refreshShellIntegrationQuality(cachedTerminal);
return cachedTerminal;
}
const shell = await this._getCopilotShell();
const toolTerminal = await this._terminalToolCreator.createTerminal(shell, token);
const shellOrProfile = await this._getCopilotShellOrProfile();
const toolTerminal = await this._terminalToolCreator.createTerminal(shellOrProfile, token);
this._registerInputListener(toolTerminal);
this._sessionTerminalAssociations.set(chatSessionId, toolTerminal);
if (token.isCancellationRequested) {
@@ -8,6 +8,7 @@ import type { IJSONSchema } from '../../../../../base/common/jsonSchema.js';
import { localize } from '../../../../../nls.js';
import { type IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js';
import { TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js';
import { terminalProfileBaseProperties } from '../../../../../platform/terminal/common/terminalPlatformConfiguration.js';
export const enum TerminalChatAgentToolsSettingId {
EnableAutoApprove = 'chat.tools.terminal.enableAutoApprove',
@@ -15,6 +16,10 @@ export const enum TerminalChatAgentToolsSettingId {
ShellIntegrationTimeout = 'chat.tools.terminal.shellIntegrationTimeout',
AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts',
TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux',
TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx',
TerminalProfileWindows = 'chat.tools.terminal.terminalProfile.windows',
DeprecatedAutoApproveCompatible = 'chat.agent.terminal.autoApprove',
DeprecatedAutoApprove1 = 'chat.agent.terminal.allowList',
DeprecatedAutoApprove2 = 'chat.agent.terminal.denyList',
@@ -41,6 +46,18 @@ const autoApproveBoolean: IJSONSchema = {
description: localize('autoApprove.key', "The start of a command to match against. A regular expression can be provided by wrapping the string in `/` characters."),
};
const terminalChatAgentProfileSchema: IJSONSchema = {
type: 'object',
required: ['path'],
properties: {
path: {
description: localize('terminalChatAgentProfile.path', "A single path to a shell executable."),
type: 'string',
},
...terminalProfileBaseProperties,
}
};
export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
[TerminalChatAgentToolsSettingId.AutoApprove]: {
markdownDescription: [
@@ -312,6 +329,57 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
maximum: 60000,
default: -1
},
[TerminalChatAgentToolsSettingId.TerminalProfileLinux]: {
restricted: true,
markdownDescription: localize('terminalChatAgentProfile.linux', "The terminal profile to use on Linux for chat agent's run in terminal tool."),
type: ['object', 'null'],
default: null,
'anyOf': [
{ type: 'null' },
terminalChatAgentProfileSchema
],
defaultSnippets: [
{
body: {
path: '${1}'
}
}
]
},
[TerminalChatAgentToolsSettingId.TerminalProfileMacOs]: {
restricted: true,
markdownDescription: localize('terminalChatAgentProfile.osx', "The terminal profile to use on macOS for chat agent's run in terminal tool."),
type: ['object', 'null'],
default: null,
'anyOf': [
{ type: 'null' },
terminalChatAgentProfileSchema
],
defaultSnippets: [
{
body: {
path: '${1}'
}
}
]
},
[TerminalChatAgentToolsSettingId.TerminalProfileWindows]: {
restricted: true,
markdownDescription: localize('terminalChatAgentProfile.windows', "The terminal profile to use on Windows for chat agent's run in terminal tool."),
type: ['object', 'null'],
default: null,
'anyOf': [
{ type: 'null' },
terminalChatAgentProfileSchema
],
defaultSnippets: [
{
body: {
path: '${1}'
}
}
]
},
[TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: {
type: 'boolean',
default: false,
@@ -30,6 +30,9 @@ class TestRunInTerminalTool extends RunInTerminalTool {
get commandLineAutoApprover() { return this._commandLineAutoApprover; }
get sessionTerminalAssociations() { return this._sessionTerminalAssociations; }
getCopilotShellOrProfile() {
return this._getCopilotShellOrProfile();
}
setBackendOs(os: OperatingSystem) {
this._osBackend = Promise.resolve(os);
}
@@ -926,4 +929,23 @@ suite('RunInTerminalTool', () => {
strictEqual(count(autoApproveInfo.value, 'echo'), 1);
});
});
suite('getCopilotShellOrProfile', () => {
test('should return custom profile when configured', async () => {
runInTerminalTool.setBackendOs(OperatingSystem.Windows);
const customProfile = Object.freeze({ path: 'C:\\Windows\\System32\\powershell.exe', args: ['-NoProfile'] });
setConfig(TerminalChatAgentToolsSettingId.TerminalProfileWindows, customProfile);
const result = await runInTerminalTool.getCopilotShellOrProfile();
strictEqual(result, customProfile);
});
test('should fall back to default shell when no custom profile is configured', async () => {
runInTerminalTool.setBackendOs(OperatingSystem.Linux);
setConfig(TerminalChatAgentToolsSettingId.TerminalProfileLinux, null);
const result = await runInTerminalTool.getCopilotShellOrProfile();
strictEqual(result, 'pwsh'); // From the mock ITerminalProfileResolverService
});
});
});