Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { promises as fs } from 'fs';
7
import { Terminal, TerminalLocation, TerminalOptions, TerminalProfile, ThemeIcon, Uri, ViewColumn, window, workspace } from 'vscode';
8
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
9
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
10
import { IEnvService } from '../../../platform/env/common/envService';
11
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
12
import { ILogService } from '../../../platform/log/common/logService';
13
import { deriveCopilotCliOTelEnv } from '../../../platform/otel/common/agentOTelEnv';
14
import { IOTelService } from '../../../platform/otel/common/otelService';
15
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
16
import { ITerminalService } from '../../../platform/terminal/common/terminalService';
17
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
18
import { createServiceIdentifier } from '../../../util/common/services';
19
import { disposableTimeout } from '../../../util/vs/base/common/async';
20
import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';
21
import * as path from '../../../util/vs/base/common/path';
22
import { windowsToGitBashPath } from '../../../util/vs/workbench/contrib/terminalContrib/suggest/browser/terminalGitBashHelpers';
23
import { PythonTerminalService } from './copilotCLIPythonTerminalService';
24
import { CopilotCLITerminalLinkProvider, SessionDirResolver } from './copilotCLITerminalLinkProvider';
25
26
//@ts-ignore
27
import powershellScript from './copilotCLIShim.ps1';
28
29
const COPILOT_CLI_SHIM_JS = 'copilotCLIShim.js';
30
const COPILOT_CLI_COMMAND = 'copilot';
31
const COPILOT_ICON = new ThemeIcon('copilot');
32
33
export type TerminalOpenLocation = 'panel' | 'editor' | 'editorBeside';
34
35
export interface ICopilotCLITerminalIntegration extends Disposable {
36
readonly _serviceBrand: undefined;
37
openTerminal(name: string, cliArgs?: string[], cwd?: string, location?: TerminalOpenLocation): Promise<Terminal | undefined>;
38
/**
39
* Sets the session-state directory used to resolve relative CLI paths.
40
*/
41
setTerminalSessionDir(terminal: Terminal, sessionDir: Uri): void;
42
/**
43
* Sets a resolver used when no session directory is set on a terminal.
44
*/
45
setSessionDirResolver(resolver: SessionDirResolver): void;
46
}
47
48
type IShellInfo = {
49
shell: 'zsh' | 'bash' | 'pwsh' | 'powershell' | 'cmd' | 'fish';
50
shellPath: string;
51
shellArgs: string[];
52
iconPath?: ThemeIcon;
53
copilotCommand: string;
54
exitCommand: string | undefined;
55
};
56
57
export const ICopilotCLITerminalIntegration = createServiceIdentifier<ICopilotCLITerminalIntegration>('ICopilotCLITerminalIntegration');
58
59
export class CopilotCLITerminalIntegration extends Disposable implements ICopilotCLITerminalIntegration {
60
declare _serviceBrand: undefined;
61
private readonly initialization: Promise<void>;
62
private shellScriptPath: string | undefined;
63
/**
64
* On Windows only: a POSIX shell script (no extension) that Git Bash / MSYS bash
65
* can execute. Used when the user's default shell is `bash.exe`, since bash cannot
66
* run the `copilot.bat` shim.
67
*/
68
private posixShellScriptPath: string | undefined;
69
private powershellScriptPath: string | undefined;
70
private readonly pythonTerminalService: PythonTerminalService;
71
private readonly _linkProvider: CopilotCLITerminalLinkProvider | undefined;
72
constructor(
73
@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,
74
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
75
@ITerminalService private readonly terminalService: ITerminalService,
76
@IEnvService private readonly envService: IEnvService,
77
@ILogService logService: ILogService,
78
@ITelemetryService private readonly telemetryService: ITelemetryService,
79
@IConfigurationService configurationService: IConfigurationService,
80
@IWorkspaceService workspaceService: IWorkspaceService,
81
@IOTelService private readonly _otelService: IOTelService,
82
) {
83
super();
84
this.pythonTerminalService = new PythonTerminalService(logService);
85
if (configurationService.getConfig(ConfigKey.Advanced.CLITerminalLinks)) {
86
this._linkProvider = new CopilotCLITerminalLinkProvider(logService, workspaceService);
87
this._register(window.registerTerminalLinkProvider(this._linkProvider));
88
}
89
this.initialization = this.initialize();
90
}
91
92
private async initialize(): Promise<void> {
93
const globalStorageUri = this.context.globalStorageUri;
94
if (!globalStorageUri) {
95
// globalStorageUri is not available in extension tests
96
return;
97
}
98
99
const storageLocation = path.join(globalStorageUri.fsPath, 'copilotCli');
100
this.terminalService.contributePath('copilot-cli', storageLocation, { command: COPILOT_CLI_COMMAND }, true);
101
102
await fs.mkdir(storageLocation, { recursive: true });
103
104
if (process.platform === 'win32') {
105
this.powershellScriptPath = path.join(storageLocation, `${COPILOT_CLI_COMMAND}.ps1`);
106
await fs.writeFile(this.powershellScriptPath, powershellScript);
107
const copilotPowershellScript = `@echo off
108
powershell -ExecutionPolicy Bypass -File "${this.powershellScriptPath}" %*
109
`;
110
this.shellScriptPath = path.join(storageLocation, `${COPILOT_CLI_COMMAND}.bat`);
111
await fs.writeFile(this.shellScriptPath, copilotPowershellScript);
112
113
// Also create a POSIX shell script for Git Bash on Windows. Bash cannot
114
// execute the .bat shim directly inside a `bash -c` string, and we cannot run
115
// the JS shim via Electron-as-node here because Electron on Windows does not
116
// support console stdin (see copilotCLIShim.ts header). Instead, delegate to
117
// the existing .bat shim, which routes through cmd.exe -> PowerShell where
118
// console stdin works correctly.
119
const posixBatPath = windowsToGitBashPath(this.shellScriptPath);
120
const copilotBashScript = `#!/bin/sh\nexec "${posixBatPath}" "$@"\n`;
121
this.posixShellScriptPath = path.join(storageLocation, COPILOT_CLI_COMMAND);
122
await fs.writeFile(this.posixShellScriptPath, copilotBashScript);
123
} else {
124
const copilotShellScript = `#!/bin/sh
125
unset NODE_OPTIONS
126
ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPILOT_CLI_SHIM_JS)}" "$@"`;
127
await fs.copyFile(path.join(__dirname, COPILOT_CLI_SHIM_JS), path.join(storageLocation, COPILOT_CLI_SHIM_JS));
128
this.shellScriptPath = path.join(storageLocation, COPILOT_CLI_COMMAND);
129
this.powershellScriptPath = path.join(storageLocation, `copilotCLIShim.ps1`);
130
await fs.writeFile(this.shellScriptPath, copilotShellScript);
131
await fs.writeFile(this.powershellScriptPath, powershellScript);
132
await fs.chmod(this.shellScriptPath, 0o750);
133
}
134
135
const provideTerminalProfile = async () => {
136
const shellInfo = await this.getShellInfo([]);
137
const options = await getCommonTerminalOptions('GitHub Copilot CLI', this._authenticationService, this._otelService, 'panel');
138
this.sendTerminalOpenTelemetry('new', shellInfo?.shell ?? 'unknown', 'newFromTerminalProfile', 'panel');
139
if (!shellInfo) {
140
// Create a profile with the user's default shell as a fallback.
141
return new TerminalProfile({
142
...options,
143
titleTemplate: '${sequence}',
144
iconPath: COPILOT_ICON,
145
});
146
}
147
return new TerminalProfile({
148
...options,
149
titleTemplate: '${sequence}',
150
shellPath: shellInfo.shellPath,
151
shellArgs: shellInfo.shellArgs,
152
iconPath: shellInfo.iconPath,
153
});
154
};
155
this._register(window.registerTerminalProfileProvider('copilot-cli', { provideTerminalProfile }));
156
157
}
158
159
public setTerminalSessionDir(terminal: Terminal, sessionDir: Uri): void {
160
this._linkProvider?.setSessionDir(terminal, sessionDir);
161
}
162
163
public setSessionDirResolver(resolver: SessionDirResolver): void {
164
this._linkProvider?.setSessionDirResolver(resolver);
165
}
166
167
public async openTerminal(name: string, cliArgs: string[] = [], cwd?: string, location: TerminalOpenLocation = 'editor'): Promise<Terminal | undefined> {
168
// Capture session type before mutating cliArgs.
169
// If cliArgs are provided (e.g. --resume), we are resuming a session; otherwise it's a new session.
170
const sessionType = cliArgs.length > 0 ? 'resume' : 'new';
171
172
// Generate another set of shell args, but with --clear to clear the terminal before running the command.
173
// We'd like to hide all of the custom shell commands we send to the terminal from the user.
174
cliArgs.unshift('--clear');
175
176
let [shellPathAndArgs] = await Promise.all([
177
this.getShellInfo(cliArgs),
178
this.initialization
179
]);
180
181
const options = await getCommonTerminalOptions(name, this._authenticationService, this._otelService, location);
182
options.cwd = cwd;
183
if (shellPathAndArgs) {
184
options.iconPath = shellPathAndArgs.iconPath ?? options.iconPath;
185
}
186
187
if (shellPathAndArgs && (shellPathAndArgs.shell !== 'powershell' && shellPathAndArgs.shell !== 'pwsh')) {
188
const terminal = await this.pythonTerminalService.createTerminal(options);
189
if (terminal) {
190
this._register(terminal);
191
this._linkProvider?.registerTerminal(terminal);
192
const command = this.buildCommandForPythonTerminal(shellPathAndArgs?.copilotCommand, cliArgs, shellPathAndArgs);
193
await this.sendCommandToTerminal(terminal, command, true, shellPathAndArgs);
194
this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'pythonTerminal', location);
195
return terminal;
196
}
197
}
198
199
if (!shellPathAndArgs) {
200
const terminal = this._register(this.terminalService.createTerminal(options));
201
this._linkProvider?.registerTerminal(terminal);
202
cliArgs.shift(); // Remove --clear as we can't run it without a shell integration
203
const command = this.buildCommandForTerminal(terminal, COPILOT_CLI_COMMAND, cliArgs);
204
await this.sendCommandToTerminal(terminal, command, false, shellPathAndArgs);
205
this.sendTerminalOpenTelemetry(sessionType, 'unknown', 'fallbackTerminal', location);
206
return terminal;
207
}
208
209
cliArgs.shift(); // Remove --clear as we are creating a new terminal with our own args.
210
shellPathAndArgs = await this.getShellInfo(cliArgs);
211
if (shellPathAndArgs) {
212
options.shellPath = shellPathAndArgs.shellPath;
213
options.shellArgs = shellPathAndArgs.shellArgs;
214
const terminal = this._register(this.terminalService.createTerminal(options));
215
this._linkProvider?.registerTerminal(terminal);
216
terminal.show();
217
this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'shellArgsTerminal', location);
218
return terminal;
219
}
220
221
return undefined;
222
}
223
224
private sendTerminalOpenTelemetry(sessionType: string, shell: string, terminalCreationMethod: string, location: TerminalOpenLocation): void {
225
/* __GDPR__
226
"copilotcli.terminal.open" : {
227
"owner": "DonJayamanne",
228
"comment": "Event sent when a Copilot CLI terminal is opened.",
229
"sessionType" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the terminal is for a new session or resuming an existing one." },
230
"shell" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The shell type used for the terminal." },
231
"terminalCreationMethod" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How the terminal was created." },
232
"location" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Where the terminal was opened - panel, editor area (active), or editor area (beside)." }
233
}
234
*/
235
this.telemetryService.sendMSFTTelemetryEvent('copilotcli.terminal.open', {
236
sessionType,
237
shell,
238
terminalCreationMethod,
239
location
240
});
241
}
242
243
private buildCommandForPythonTerminal(copilotCommand: string, cliArgs: string[], shellInfo: IShellInfo) {
244
let commandPrefix = '';
245
if (shellInfo.shell === 'zsh' || shellInfo.shell === 'bash' || shellInfo.shell === 'fish') {
246
// Starting with empty space to hide from terminal history
247
commandPrefix = ' ';
248
}
249
if (shellInfo.shell === 'powershell' || shellInfo.shell === 'pwsh') {
250
// Run powershell script
251
commandPrefix = '& ';
252
}
253
254
const exitCommand = shellInfo.exitCommand || '';
255
256
return `${commandPrefix}${quoteArgsForShell(copilotCommand, [])} ${cliArgs.join(' ')} ${exitCommand}`;
257
}
258
259
private buildCommandForTerminal(terminal: Terminal, copilotCommand: string, cliArgs: string[]) {
260
return `${quoteArgsForShell(copilotCommand, [])} ${cliArgs.join(' ')}`;
261
}
262
263
private async sendCommandToTerminal(terminal: Terminal, command: string, waitForPythonActivation: boolean, shellInfo: IShellInfo | undefined = undefined): Promise<void> {
264
// Wait for shell integration to be available
265
const shellIntegrationTimeout = 3000;
266
let shellIntegrationAvailable = terminal.shellIntegration ? true : false;
267
const disposables = new DisposableStore();
268
const integrationPromise = shellIntegrationAvailable ? Promise.resolve() : new Promise<void>((resolve) => {
269
const disposable = disposables.add(this.terminalService.onDidChangeTerminalShellIntegration(e => {
270
if (e.terminal === terminal && e.shellIntegration) {
271
shellIntegrationAvailable = true;
272
disposable.dispose();
273
resolve();
274
}
275
}));
276
277
disposables.add(disposableTimeout(() => {
278
disposable.dispose();
279
resolve();
280
}, shellIntegrationTimeout));
281
});
282
283
try {
284
await integrationPromise;
285
286
if (waitForPythonActivation) {
287
// Wait for python extension to send its initialization commands.
288
// Else if we send too early, the copilot command might not get executed properly.
289
// Activating powershell scripts can take longer, so wait a bit more.
290
const delay = (shellInfo?.shell === 'powershell' || shellInfo?.shell === 'pwsh') ? 3000 : 1000;
291
await new Promise<void>(resolve => disposables.add(disposableTimeout(resolve, delay))); // Wait a bit to ensure the terminal is ready
292
}
293
294
if (terminal.shellIntegration) {
295
terminal.shellIntegration.executeCommand(command);
296
} else {
297
terminal.sendText(command);
298
}
299
300
terminal.show();
301
} finally {
302
disposables.dispose();
303
}
304
}
305
306
private async getShellInfo(cliArgs: string[]): Promise<IShellInfo | undefined> {
307
const configPlatform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'osx' : 'linux';
308
309
// vscode.env.shell already resolves to the user's configured default terminal profile path.
310
const shellPath = this.envService.shell;
311
const defaultProfileName = workspace.getConfiguration('terminal').get<string | undefined>(`integrated.defaultProfile.${configPlatform}`);
312
let shellArgs: string[] = [];
313
if (defaultProfileName) {
314
const profiles = workspace.getConfiguration('terminal').get<Record<string, { path?: string | string[]; args?: string[] }>>(`integrated.profiles.${configPlatform}`);
315
const profileArgs = profiles?.[defaultProfileName]?.args;
316
shellArgs = Array.isArray(profileArgs) ? profileArgs : [];
317
}
318
319
// Detect shell type from the resolved shell path basename,
320
// matching how getShellIntegrationInjection() does it in terminalEnvironment.ts
321
const shellBasename = process.platform === 'win32'
322
? path.basename(shellPath).toLowerCase()
323
: path.basename(shellPath);
324
const iconPath = COPILOT_ICON;
325
326
if (shellBasename === 'zsh' && this.shellScriptPath) {
327
return {
328
shell: 'zsh',
329
shellPath,
330
shellArgs: [`-ci${shellArgs.includes('-l') ? 'l' : ''}`, quoteArgsForShell(this.shellScriptPath, cliArgs)],
331
iconPath,
332
copilotCommand: this.shellScriptPath,
333
exitCommand: `&& exit`
334
};
335
} else if ((shellBasename === 'bash' || shellBasename === 'bash.exe') && (configPlatform === 'windows' ? this.posixShellScriptPath : this.shellScriptPath)) {
336
// On Windows (Git Bash), use the POSIX shim and reference it by its MSYS path,
337
// since the path lives inside the `-ic` shell-string and is not translated by MSYS.
338
const scriptPath = configPlatform === 'windows' ? this.posixShellScriptPath! : this.shellScriptPath!;
339
const bashScriptPath = configPlatform === 'windows' ? windowsToGitBashPath(scriptPath) : scriptPath;
340
return {
341
shell: 'bash',
342
shellPath,
343
shellArgs: [`-${shellArgs.includes('-l') ? 'l' : ''}ic`, quoteArgsForShell(bashScriptPath, cliArgs)],
344
iconPath,
345
copilotCommand: bashScriptPath,
346
exitCommand: `&& exit`
347
};
348
} else if (shellBasename === 'fish' && this.shellScriptPath) {
349
const fishArgs: string[] = [];
350
if (shellArgs.includes('-l')) {
351
fishArgs.push('-l');
352
}
353
fishArgs.push('-c', quoteArgsForShell(this.shellScriptPath, cliArgs));
354
return {
355
shell: 'fish',
356
shellPath,
357
shellArgs: fishArgs,
358
iconPath,
359
copilotCommand: this.shellScriptPath,
360
exitCommand: `; and exit`
361
};
362
} else if ((shellBasename === 'pwsh' || shellBasename === 'pwsh.exe') && this.powershellScriptPath) {
363
return {
364
shell: 'pwsh',
365
shellPath,
366
shellArgs: ['-File', this.powershellScriptPath, ...cliArgs],
367
iconPath,
368
copilotCommand: this.powershellScriptPath,
369
exitCommand: `&& exit`
370
};
371
} else if ((shellBasename === 'powershell' || shellBasename === 'powershell.exe') && this.powershellScriptPath && configPlatform === 'windows') {
372
return {
373
shell: 'powershell',
374
shellPath,
375
shellArgs: ['-File', this.powershellScriptPath, ...cliArgs],
376
iconPath,
377
copilotCommand: this.powershellScriptPath,
378
exitCommand: `&& exit`
379
};
380
} else if ((shellBasename === 'cmd' || shellBasename === 'cmd.exe') && this.shellScriptPath && configPlatform === 'windows') {
381
return {
382
shell: 'cmd',
383
shellPath,
384
shellArgs: ['/c', this.shellScriptPath, ...cliArgs],
385
iconPath,
386
copilotCommand: this.shellScriptPath,
387
exitCommand: '&& exit'
388
};
389
}
390
391
return undefined;
392
}
393
394
}
395
396
function quoteArgsForShell(shellScript: string, args: string[]): string {
397
const escapeArg = (arg: string): string => {
398
// If argument contains spaces, quotes, or special characters, wrap in quotes and escape internal quotes
399
if (/[\s"'$`\\|&;()<>]/.test(arg)) {
400
return `"${arg.replace(/["\\]/g, '\\$&')}"`;
401
}
402
return arg;
403
};
404
405
const escapedArgs = args.map(escapeArg);
406
return args.length ? `${escapeArg(shellScript)} ${escapedArgs.join(' ')}` : escapeArg(shellScript);
407
}
408
409
async function getCommonTerminalOptions(name: string, authenticationService: IAuthenticationService, otelService: IOTelService, location: TerminalOpenLocation = 'editor'): Promise<TerminalOptions> {
410
const options: TerminalOptions = {
411
name,
412
titleTemplate: '${sequence}',
413
iconPath: new ThemeIcon('terminal'),
414
hideFromUser: false
415
};
416
if (location === 'panel') {
417
options.location = TerminalLocation.Panel;
418
} else {
419
options.location = { viewColumn: location === 'editorBeside' ? ViewColumn.Beside : ViewColumn.Active };
420
}
421
const session = await authenticationService.getGitHubSession('any', { silent: true });
422
if (session) {
423
options.env = {
424
// Old Token name for GitHub integrations (deprecate once the new variable has been adopted widely)
425
GH_TOKEN: session.accessToken,
426
// New Token name for Copilot
427
COPILOT_GITHUB_TOKEN: session.accessToken,
428
// Forward OTel config so the CLI binary exports traces/metrics to the same endpoint.
429
// Pass an empty env so all vars are explicitly included in TerminalOptions.env,
430
// regardless of process.env state (which may have stale values from the
431
// in-process background agent). TerminalOptions.env overlays the inherited
432
// process.env, so explicit entries here take precedence.
433
...(otelService.config.enabled ? deriveCopilotCliOTelEnv(otelService.config, {}) : {}),
434
};
435
}
436
return options;
437
}
438
439