Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts
13399 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 { promises as fs } from 'fs';6import { Terminal, TerminalLocation, TerminalOptions, TerminalProfile, ThemeIcon, Uri, ViewColumn, window, workspace } from 'vscode';7import { IAuthenticationService } from '../../../platform/authentication/common/authentication';8import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';9import { IEnvService } from '../../../platform/env/common/envService';10import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';11import { ILogService } from '../../../platform/log/common/logService';12import { deriveCopilotCliOTelEnv } from '../../../platform/otel/common/agentOTelEnv';13import { IOTelService } from '../../../platform/otel/common/otelService';14import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';15import { ITerminalService } from '../../../platform/terminal/common/terminalService';16import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';17import { createServiceIdentifier } from '../../../util/common/services';18import { disposableTimeout } from '../../../util/vs/base/common/async';19import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';20import * as path from '../../../util/vs/base/common/path';21import { windowsToGitBashPath } from '../../../util/vs/workbench/contrib/terminalContrib/suggest/browser/terminalGitBashHelpers';22import { PythonTerminalService } from './copilotCLIPythonTerminalService';23import { CopilotCLITerminalLinkProvider, SessionDirResolver } from './copilotCLITerminalLinkProvider';2425//@ts-ignore26import powershellScript from './copilotCLIShim.ps1';2728const COPILOT_CLI_SHIM_JS = 'copilotCLIShim.js';29const COPILOT_CLI_COMMAND = 'copilot';30const COPILOT_ICON = new ThemeIcon('copilot');3132export type TerminalOpenLocation = 'panel' | 'editor' | 'editorBeside';3334export interface ICopilotCLITerminalIntegration extends Disposable {35readonly _serviceBrand: undefined;36openTerminal(name: string, cliArgs?: string[], cwd?: string, location?: TerminalOpenLocation): Promise<Terminal | undefined>;37/**38* Sets the session-state directory used to resolve relative CLI paths.39*/40setTerminalSessionDir(terminal: Terminal, sessionDir: Uri): void;41/**42* Sets a resolver used when no session directory is set on a terminal.43*/44setSessionDirResolver(resolver: SessionDirResolver): void;45}4647type IShellInfo = {48shell: 'zsh' | 'bash' | 'pwsh' | 'powershell' | 'cmd' | 'fish';49shellPath: string;50shellArgs: string[];51iconPath?: ThemeIcon;52copilotCommand: string;53exitCommand: string | undefined;54};5556export const ICopilotCLITerminalIntegration = createServiceIdentifier<ICopilotCLITerminalIntegration>('ICopilotCLITerminalIntegration');5758export class CopilotCLITerminalIntegration extends Disposable implements ICopilotCLITerminalIntegration {59declare _serviceBrand: undefined;60private readonly initialization: Promise<void>;61private shellScriptPath: string | undefined;62/**63* On Windows only: a POSIX shell script (no extension) that Git Bash / MSYS bash64* can execute. Used when the user's default shell is `bash.exe`, since bash cannot65* run the `copilot.bat` shim.66*/67private posixShellScriptPath: string | undefined;68private powershellScriptPath: string | undefined;69private readonly pythonTerminalService: PythonTerminalService;70private readonly _linkProvider: CopilotCLITerminalLinkProvider | undefined;71constructor(72@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,73@IAuthenticationService private readonly _authenticationService: IAuthenticationService,74@ITerminalService private readonly terminalService: ITerminalService,75@IEnvService private readonly envService: IEnvService,76@ILogService logService: ILogService,77@ITelemetryService private readonly telemetryService: ITelemetryService,78@IConfigurationService configurationService: IConfigurationService,79@IWorkspaceService workspaceService: IWorkspaceService,80@IOTelService private readonly _otelService: IOTelService,81) {82super();83this.pythonTerminalService = new PythonTerminalService(logService);84if (configurationService.getConfig(ConfigKey.Advanced.CLITerminalLinks)) {85this._linkProvider = new CopilotCLITerminalLinkProvider(logService, workspaceService);86this._register(window.registerTerminalLinkProvider(this._linkProvider));87}88this.initialization = this.initialize();89}9091private async initialize(): Promise<void> {92const globalStorageUri = this.context.globalStorageUri;93if (!globalStorageUri) {94// globalStorageUri is not available in extension tests95return;96}9798const storageLocation = path.join(globalStorageUri.fsPath, 'copilotCli');99this.terminalService.contributePath('copilot-cli', storageLocation, { command: COPILOT_CLI_COMMAND }, true);100101await fs.mkdir(storageLocation, { recursive: true });102103if (process.platform === 'win32') {104this.powershellScriptPath = path.join(storageLocation, `${COPILOT_CLI_COMMAND}.ps1`);105await fs.writeFile(this.powershellScriptPath, powershellScript);106const copilotPowershellScript = `@echo off107powershell -ExecutionPolicy Bypass -File "${this.powershellScriptPath}" %*108`;109this.shellScriptPath = path.join(storageLocation, `${COPILOT_CLI_COMMAND}.bat`);110await fs.writeFile(this.shellScriptPath, copilotPowershellScript);111112// Also create a POSIX shell script for Git Bash on Windows. Bash cannot113// execute the .bat shim directly inside a `bash -c` string, and we cannot run114// the JS shim via Electron-as-node here because Electron on Windows does not115// support console stdin (see copilotCLIShim.ts header). Instead, delegate to116// the existing .bat shim, which routes through cmd.exe -> PowerShell where117// console stdin works correctly.118const posixBatPath = windowsToGitBashPath(this.shellScriptPath);119const copilotBashScript = `#!/bin/sh\nexec "${posixBatPath}" "$@"\n`;120this.posixShellScriptPath = path.join(storageLocation, COPILOT_CLI_COMMAND);121await fs.writeFile(this.posixShellScriptPath, copilotBashScript);122} else {123const copilotShellScript = `#!/bin/sh124unset NODE_OPTIONS125ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPILOT_CLI_SHIM_JS)}" "$@"`;126await fs.copyFile(path.join(__dirname, COPILOT_CLI_SHIM_JS), path.join(storageLocation, COPILOT_CLI_SHIM_JS));127this.shellScriptPath = path.join(storageLocation, COPILOT_CLI_COMMAND);128this.powershellScriptPath = path.join(storageLocation, `copilotCLIShim.ps1`);129await fs.writeFile(this.shellScriptPath, copilotShellScript);130await fs.writeFile(this.powershellScriptPath, powershellScript);131await fs.chmod(this.shellScriptPath, 0o750);132}133134const provideTerminalProfile = async () => {135const shellInfo = await this.getShellInfo([]);136const options = await getCommonTerminalOptions('GitHub Copilot CLI', this._authenticationService, this._otelService, 'panel');137this.sendTerminalOpenTelemetry('new', shellInfo?.shell ?? 'unknown', 'newFromTerminalProfile', 'panel');138if (!shellInfo) {139// Create a profile with the user's default shell as a fallback.140return new TerminalProfile({141...options,142titleTemplate: '${sequence}',143iconPath: COPILOT_ICON,144});145}146return new TerminalProfile({147...options,148titleTemplate: '${sequence}',149shellPath: shellInfo.shellPath,150shellArgs: shellInfo.shellArgs,151iconPath: shellInfo.iconPath,152});153};154this._register(window.registerTerminalProfileProvider('copilot-cli', { provideTerminalProfile }));155156}157158public setTerminalSessionDir(terminal: Terminal, sessionDir: Uri): void {159this._linkProvider?.setSessionDir(terminal, sessionDir);160}161162public setSessionDirResolver(resolver: SessionDirResolver): void {163this._linkProvider?.setSessionDirResolver(resolver);164}165166public async openTerminal(name: string, cliArgs: string[] = [], cwd?: string, location: TerminalOpenLocation = 'editor'): Promise<Terminal | undefined> {167// Capture session type before mutating cliArgs.168// If cliArgs are provided (e.g. --resume), we are resuming a session; otherwise it's a new session.169const sessionType = cliArgs.length > 0 ? 'resume' : 'new';170171// Generate another set of shell args, but with --clear to clear the terminal before running the command.172// We'd like to hide all of the custom shell commands we send to the terminal from the user.173cliArgs.unshift('--clear');174175let [shellPathAndArgs] = await Promise.all([176this.getShellInfo(cliArgs),177this.initialization178]);179180const options = await getCommonTerminalOptions(name, this._authenticationService, this._otelService, location);181options.cwd = cwd;182if (shellPathAndArgs) {183options.iconPath = shellPathAndArgs.iconPath ?? options.iconPath;184}185186if (shellPathAndArgs && (shellPathAndArgs.shell !== 'powershell' && shellPathAndArgs.shell !== 'pwsh')) {187const terminal = await this.pythonTerminalService.createTerminal(options);188if (terminal) {189this._register(terminal);190this._linkProvider?.registerTerminal(terminal);191const command = this.buildCommandForPythonTerminal(shellPathAndArgs?.copilotCommand, cliArgs, shellPathAndArgs);192await this.sendCommandToTerminal(terminal, command, true, shellPathAndArgs);193this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'pythonTerminal', location);194return terminal;195}196}197198if (!shellPathAndArgs) {199const terminal = this._register(this.terminalService.createTerminal(options));200this._linkProvider?.registerTerminal(terminal);201cliArgs.shift(); // Remove --clear as we can't run it without a shell integration202const command = this.buildCommandForTerminal(terminal, COPILOT_CLI_COMMAND, cliArgs);203await this.sendCommandToTerminal(terminal, command, false, shellPathAndArgs);204this.sendTerminalOpenTelemetry(sessionType, 'unknown', 'fallbackTerminal', location);205return terminal;206}207208cliArgs.shift(); // Remove --clear as we are creating a new terminal with our own args.209shellPathAndArgs = await this.getShellInfo(cliArgs);210if (shellPathAndArgs) {211options.shellPath = shellPathAndArgs.shellPath;212options.shellArgs = shellPathAndArgs.shellArgs;213const terminal = this._register(this.terminalService.createTerminal(options));214this._linkProvider?.registerTerminal(terminal);215terminal.show();216this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'shellArgsTerminal', location);217return terminal;218}219220return undefined;221}222223private sendTerminalOpenTelemetry(sessionType: string, shell: string, terminalCreationMethod: string, location: TerminalOpenLocation): void {224/* __GDPR__225"copilotcli.terminal.open" : {226"owner": "DonJayamanne",227"comment": "Event sent when a Copilot CLI terminal is opened.",228"sessionType" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the terminal is for a new session or resuming an existing one." },229"shell" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The shell type used for the terminal." },230"terminalCreationMethod" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How the terminal was created." },231"location" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Where the terminal was opened - panel, editor area (active), or editor area (beside)." }232}233*/234this.telemetryService.sendMSFTTelemetryEvent('copilotcli.terminal.open', {235sessionType,236shell,237terminalCreationMethod,238location239});240}241242private buildCommandForPythonTerminal(copilotCommand: string, cliArgs: string[], shellInfo: IShellInfo) {243let commandPrefix = '';244if (shellInfo.shell === 'zsh' || shellInfo.shell === 'bash' || shellInfo.shell === 'fish') {245// Starting with empty space to hide from terminal history246commandPrefix = ' ';247}248if (shellInfo.shell === 'powershell' || shellInfo.shell === 'pwsh') {249// Run powershell script250commandPrefix = '& ';251}252253const exitCommand = shellInfo.exitCommand || '';254255return `${commandPrefix}${quoteArgsForShell(copilotCommand, [])} ${cliArgs.join(' ')} ${exitCommand}`;256}257258private buildCommandForTerminal(terminal: Terminal, copilotCommand: string, cliArgs: string[]) {259return `${quoteArgsForShell(copilotCommand, [])} ${cliArgs.join(' ')}`;260}261262private async sendCommandToTerminal(terminal: Terminal, command: string, waitForPythonActivation: boolean, shellInfo: IShellInfo | undefined = undefined): Promise<void> {263// Wait for shell integration to be available264const shellIntegrationTimeout = 3000;265let shellIntegrationAvailable = terminal.shellIntegration ? true : false;266const disposables = new DisposableStore();267const integrationPromise = shellIntegrationAvailable ? Promise.resolve() : new Promise<void>((resolve) => {268const disposable = disposables.add(this.terminalService.onDidChangeTerminalShellIntegration(e => {269if (e.terminal === terminal && e.shellIntegration) {270shellIntegrationAvailable = true;271disposable.dispose();272resolve();273}274}));275276disposables.add(disposableTimeout(() => {277disposable.dispose();278resolve();279}, shellIntegrationTimeout));280});281282try {283await integrationPromise;284285if (waitForPythonActivation) {286// Wait for python extension to send its initialization commands.287// Else if we send too early, the copilot command might not get executed properly.288// Activating powershell scripts can take longer, so wait a bit more.289const delay = (shellInfo?.shell === 'powershell' || shellInfo?.shell === 'pwsh') ? 3000 : 1000;290await new Promise<void>(resolve => disposables.add(disposableTimeout(resolve, delay))); // Wait a bit to ensure the terminal is ready291}292293if (terminal.shellIntegration) {294terminal.shellIntegration.executeCommand(command);295} else {296terminal.sendText(command);297}298299terminal.show();300} finally {301disposables.dispose();302}303}304305private async getShellInfo(cliArgs: string[]): Promise<IShellInfo | undefined> {306const configPlatform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'osx' : 'linux';307308// vscode.env.shell already resolves to the user's configured default terminal profile path.309const shellPath = this.envService.shell;310const defaultProfileName = workspace.getConfiguration('terminal').get<string | undefined>(`integrated.defaultProfile.${configPlatform}`);311let shellArgs: string[] = [];312if (defaultProfileName) {313const profiles = workspace.getConfiguration('terminal').get<Record<string, { path?: string | string[]; args?: string[] }>>(`integrated.profiles.${configPlatform}`);314const profileArgs = profiles?.[defaultProfileName]?.args;315shellArgs = Array.isArray(profileArgs) ? profileArgs : [];316}317318// Detect shell type from the resolved shell path basename,319// matching how getShellIntegrationInjection() does it in terminalEnvironment.ts320const shellBasename = process.platform === 'win32'321? path.basename(shellPath).toLowerCase()322: path.basename(shellPath);323const iconPath = COPILOT_ICON;324325if (shellBasename === 'zsh' && this.shellScriptPath) {326return {327shell: 'zsh',328shellPath,329shellArgs: [`-ci${shellArgs.includes('-l') ? 'l' : ''}`, quoteArgsForShell(this.shellScriptPath, cliArgs)],330iconPath,331copilotCommand: this.shellScriptPath,332exitCommand: `&& exit`333};334} else if ((shellBasename === 'bash' || shellBasename === 'bash.exe') && (configPlatform === 'windows' ? this.posixShellScriptPath : this.shellScriptPath)) {335// On Windows (Git Bash), use the POSIX shim and reference it by its MSYS path,336// since the path lives inside the `-ic` shell-string and is not translated by MSYS.337const scriptPath = configPlatform === 'windows' ? this.posixShellScriptPath! : this.shellScriptPath!;338const bashScriptPath = configPlatform === 'windows' ? windowsToGitBashPath(scriptPath) : scriptPath;339return {340shell: 'bash',341shellPath,342shellArgs: [`-${shellArgs.includes('-l') ? 'l' : ''}ic`, quoteArgsForShell(bashScriptPath, cliArgs)],343iconPath,344copilotCommand: bashScriptPath,345exitCommand: `&& exit`346};347} else if (shellBasename === 'fish' && this.shellScriptPath) {348const fishArgs: string[] = [];349if (shellArgs.includes('-l')) {350fishArgs.push('-l');351}352fishArgs.push('-c', quoteArgsForShell(this.shellScriptPath, cliArgs));353return {354shell: 'fish',355shellPath,356shellArgs: fishArgs,357iconPath,358copilotCommand: this.shellScriptPath,359exitCommand: `; and exit`360};361} else if ((shellBasename === 'pwsh' || shellBasename === 'pwsh.exe') && this.powershellScriptPath) {362return {363shell: 'pwsh',364shellPath,365shellArgs: ['-File', this.powershellScriptPath, ...cliArgs],366iconPath,367copilotCommand: this.powershellScriptPath,368exitCommand: `&& exit`369};370} else if ((shellBasename === 'powershell' || shellBasename === 'powershell.exe') && this.powershellScriptPath && configPlatform === 'windows') {371return {372shell: 'powershell',373shellPath,374shellArgs: ['-File', this.powershellScriptPath, ...cliArgs],375iconPath,376copilotCommand: this.powershellScriptPath,377exitCommand: `&& exit`378};379} else if ((shellBasename === 'cmd' || shellBasename === 'cmd.exe') && this.shellScriptPath && configPlatform === 'windows') {380return {381shell: 'cmd',382shellPath,383shellArgs: ['/c', this.shellScriptPath, ...cliArgs],384iconPath,385copilotCommand: this.shellScriptPath,386exitCommand: '&& exit'387};388}389390return undefined;391}392393}394395function quoteArgsForShell(shellScript: string, args: string[]): string {396const escapeArg = (arg: string): string => {397// If argument contains spaces, quotes, or special characters, wrap in quotes and escape internal quotes398if (/[\s"'$`\\|&;()<>]/.test(arg)) {399return `"${arg.replace(/["\\]/g, '\\$&')}"`;400}401return arg;402};403404const escapedArgs = args.map(escapeArg);405return args.length ? `${escapeArg(shellScript)} ${escapedArgs.join(' ')}` : escapeArg(shellScript);406}407408async function getCommonTerminalOptions(name: string, authenticationService: IAuthenticationService, otelService: IOTelService, location: TerminalOpenLocation = 'editor'): Promise<TerminalOptions> {409const options: TerminalOptions = {410name,411titleTemplate: '${sequence}',412iconPath: new ThemeIcon('terminal'),413hideFromUser: false414};415if (location === 'panel') {416options.location = TerminalLocation.Panel;417} else {418options.location = { viewColumn: location === 'editorBeside' ? ViewColumn.Beside : ViewColumn.Active };419}420const session = await authenticationService.getGitHubSession('any', { silent: true });421if (session) {422options.env = {423// Old Token name for GitHub integrations (deprecate once the new variable has been adopted widely)424GH_TOKEN: session.accessToken,425// New Token name for Copilot426COPILOT_GITHUB_TOKEN: session.accessToken,427// Forward OTel config so the CLI binary exports traces/metrics to the same endpoint.428// Pass an empty env so all vars are explicitly included in TerminalOptions.env,429// regardless of process.env state (which may have stale values from the430// in-process background agent). TerminalOptions.env overlays the inherited431// process.env, so explicit entries here take precedence.432...(otelService.config.enabled ? deriveCopilotCliOTelEnv(otelService.config, {}) : {}),433};434}435return options;436}437438439