Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.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
import type { Attachment, SendOptions, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
6
import * as l10n from '@vscode/l10n';
7
import * as vscode from 'vscode';
8
import { ChatExtendedRequestHandler, ChatRequestTurn2, Uri } from 'vscode';
9
import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService';
10
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
11
import { INativeEnvService } from '../../../platform/env/common/envService';
12
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
13
import { IGitService } from '../../../platform/git/common/gitService';
14
import { toGitUri } from '../../../platform/git/common/utils';
15
import { ILogService } from '../../../platform/log/common/logService';
16
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
17
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
18
import { isUri } from '../../../util/common/types';
19
import { DeferredPromise, IntervalTimer, raceCancellation } from '../../../util/vs/base/common/async';
20
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
21
import { isCancellationError } from '../../../util/vs/base/common/errors';
22
import { Emitter, Event } from '../../../util/vs/base/common/event';
23
import { Disposable, DisposableStore, IDisposable, IReference } from '../../../util/vs/base/common/lifecycle';
24
import { ResourceMap } from '../../../util/vs/base/common/map';
25
import { relative } from '../../../util/vs/base/common/path';
26
import { basename, dirname, extUri } from '../../../util/vs/base/common/resources';
27
import { StopWatch } from '../../../util/vs/base/common/stopwatch';
28
import { hasKey } from '../../../util/vs/base/common/types';
29
import { EXTENSION_ID } from '../../common/constants';
30
import { GitBranchNameGenerator } from '../../prompt/node/gitBranch';
31
import { IChatSessionMetadataStore, RepositoryProperties } from '../common/chatSessionMetadataStore';
32
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
33
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
34
import { IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
35
import { getWorkingDirectory, IWorkspaceInfo } from '../common/workspaceInfo';
36
import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';
37
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
38
import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext, takePendingCopilotCLIRequestContext } from '../copilotcli/common/pendingRequestContext';
39
import { SessionIdForCLI } from '../copilotcli/common/utils';
40
import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';
41
import { ICopilotCLISDK } from '../copilotcli/node/copilotCli';
42
import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver';
43
import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
44
import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
45
import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler';
46
import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';
47
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
48
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
49
import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl';
50
import { IPullRequestDetectionService } from './pullRequestDetectionService';
51
import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';
52
import { ISessionRequestLifecycle } from './sessionRequestLifecycle';
53
import { ICopilotCLIChatSessionInitializer, SessionInitOptions } from '../copilotcli/vscode-node/copilotCLIChatSessionInitializer';
54
import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences';
55
56
57
export interface ICopilotCLIChatSessionItemProvider extends IDisposable {
58
refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void>;
59
}
60
61
const OPEN_IN_COPILOT_CLI_COMMAND_ID = 'github.copilot.cli.openInCopilotCLI';
62
const CHECK_FOR_STEERING_DELAY = 100; // ms
63
64
const _invalidCopilotCLISessionIdsWithErrorMessage = new Map<string, string>();
65
66
// Re-export for backward compatibility
67
export { resolveBranchLockState, resolveBranchSelection, resolveIsolationSelection } from './sessionOptionGroupBuilder';
68
69
/**
70
* Escape XML special characters
71
*/
72
function escapeXml(text: string): string {
73
return text
74
.replace(/&/g, '&amp;')
75
.replace(/</g, '&lt;')
76
.replace(/>/g, '&gt;')
77
.replace(/"/g, '&quot;')
78
.replace(/'/g, '&apos;');
79
}
80
81
function getIssueRuntimeInfo(): { readonly platform: string; readonly vscodeInfo: string; readonly extensionVersion: string } {
82
const extensionVersion = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON?.version;
83
84
return {
85
platform: `${process.platform}-${process.arch}`,
86
vscodeInfo: `${vscode.env.appName} ${vscode.version}`,
87
extensionVersion: extensionVersion ?? 'unknown'
88
};
89
}
90
91
function getSessionLoadFailureIssueInfo(invalidSessionMessage: string): { readonly issueBody: string; readonly issueUrl: string } {
92
const runtimeInfo = getIssueRuntimeInfo();
93
const issueTitle = '[Copilot CLI] Failed to load chat session';
94
const issueBody = `## Description\n\nFailed to load a Copilot CLI chat session.\n\n## Environment\n\n- Platform: ${runtimeInfo.platform}\n- VS Code: ${runtimeInfo.vscodeInfo}\n- Chat Extension Version: ${runtimeInfo.extensionVersion}\n\n## Error\n\n\`\`\`\n${invalidSessionMessage}\n\`\`\``;
95
const issueUrl = `https://github.com/microsoft/vscode/issues/new?title=${encodeURIComponent(issueTitle)}&body=${encodeURIComponent(issueBody)}`;
96
97
return { issueBody, issueUrl };
98
}
99
100
/**
101
* Resolves candidate session directories for a CLI terminal, ordered by
102
* terminal affinity.
103
*
104
* Sessions whose owning terminal matches `terminal` are returned first so the
105
* link provider's file-existence probing hits the correct session-state dir
106
* before unrelated ones. Unrelated sessions are still included at the tail
107
* because a new session may not have registered its terminal yet (session IDs
108
* arrive later via MCP?).
109
*/
110
export async function resolveSessionDirsForTerminal(
111
sessionTracker: ICopilotCLISessionTracker,
112
terminal: vscode.Terminal,
113
): Promise<Uri[]> {
114
const activeIds = sessionTracker.getSessionIds();
115
const matching: Uri[] = [];
116
const rest: Uri[] = [];
117
for (const id of activeIds) {
118
const sessionTerminal = await sessionTracker.getTerminal(id);
119
const dir = Uri.file(getCopilotCLISessionDir(id));
120
if (sessionTerminal === terminal) {
121
matching.push(dir);
122
} else {
123
rest.push(dir);
124
}
125
}
126
return [...matching, ...rest];
127
}
128
129
export class CopilotCLIChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider, ICopilotCLIChatSessionItemProvider {
130
private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
131
public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;
132
133
private readonly controller: vscode.ChatSessionItemController;
134
private readonly newSessions = new ResourceMap<vscode.ChatSessionItem>();
135
constructor(
136
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
137
@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
138
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
139
@IConfigurationService private readonly configurationService: IConfigurationService,
140
@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,
141
@IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService,
142
@ILogService private readonly logService: ILogService,
143
@IPullRequestDetectionService private readonly _prDetectionService: IPullRequestDetectionService,
144
@ISessionOptionGroupBuilder private readonly _optionGroupBuilder: ISessionOptionGroupBuilder,
145
@IGitService private readonly _gitService: IGitService,
146
@IChatSessionWorkspaceFolderService private readonly _workspaceFolderService: IChatSessionWorkspaceFolderService,
147
@IChatSessionMetadataStore private readonly _metadataStore: IChatSessionMetadataStore,
148
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
149
@IChatSessionWorktreeService chatSessionWorktreeService: IChatSessionWorktreeService,
150
) {
151
super();
152
153
let isRefreshing = false;
154
const refreshSessions = async () => {
155
if (isRefreshing) {
156
return;
157
}
158
isRefreshing = true;
159
const stopwatch = new StopWatch();
160
void this._metadataStore.refresh().catch(error => this.logService.error(error, 'Failed to refresh session metadata store during session list refresh'));
161
try {
162
const sessions = await this.sessionService.getAllSessions(CancellationToken.None);
163
const items = await Promise.all(sessions.map(async session => this.toChatSessionItem(session)));
164
165
const count = items.length;
166
void this.commandExecutionService.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0);
167
168
controller.items.replace(items);
169
} finally {
170
isRefreshing = false;
171
this.logService.info(`[CopilotCLIChatSessionContentProvider] listSessions took ${stopwatch.elapsed()}ms`);
172
}
173
};
174
const controller = this.controller = this._register(vscode.chat.createChatSessionItemController(
175
'copilotcli',
176
async () => {
177
await refreshSessions();
178
}
179
));
180
this._register(configurationService.onDidChangeConfiguration(e => {
181
if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) {
182
void refreshSessions();
183
}
184
}));
185
this._register(this._workspaceFolderService.onDidChangeWorkspaceFolderChanges(e => {
186
this.refreshSession({ reason: 'update', sessionId: e.sessionId });
187
}));
188
this._register(chatSessionWorktreeService.onDidChangeWorktreeChanges(e => {
189
this.refreshSession({ reason: 'update', sessionId: e.sessionId });
190
}));
191
controller.newChatSessionItemHandler = async (context) => {
192
const sessionId = this.sessionService.createNewSessionId();
193
const resource = SessionIdForCLI.getResource(sessionId);
194
const session = controller.createChatSessionItem(resource, context.request.prompt ?? context.request.command ?? '');
195
this.customSessionTitleService.generateSessionTitle(sessionId, context.request, CancellationToken.None)
196
.then(async title => {
197
if (title) {
198
await this.customSessionTitleService.setCustomSessionTitle(sessionId, title);
199
}
200
// Given we're done generating a title, refresh the contents of this session so that the new title is picked up.
201
if (this.controller.items.get(resource)) {
202
this.refreshSession({ reason: 'update', sessionId }).catch(() => { /* expected if session was deleted */ });
203
}
204
})
205
.catch(ex => this.logService.error(ex, 'Failed to generate custom session title'));
206
207
controller.items.add(session);
208
this.newSessions.set(resource, session);
209
return session;
210
};
211
if (this.configurationService.getConfig(ConfigKey.Advanced.CLIForkSessionsEnabled)) {
212
controller.forkHandler = async (sessionResource: Uri, request: ChatRequestTurn2 | undefined, token: vscode.CancellationToken) => {
213
const sessionId = SessionIdForCLI.parse(sessionResource);
214
const folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, undefined, token);
215
const forkedSessionId = await this.sessionService.forkSession({ sessionId, requestId: request?.id, workspace: folderInfo }, token);
216
const item = await this.sessionService.getSessionItem(forkedSessionId, token);
217
if (!item) {
218
throw new Error(`Failed to get session item for forked session ${forkedSessionId}`);
219
}
220
return this.toChatSessionItem(item, undefined, token);
221
};
222
}
223
// Defers the slow `buildChanges` (git diff) call to when the editor renders the item.
224
if (this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
225
controller.resolveChatSessionItem = async (item, token) => {
226
const sessionId = SessionIdForCLI.parse(item.resource);
227
const session = await this.sessionService.getSessionItem(sessionId, token);
228
if (!session || token.isCancellationRequested) {
229
return;
230
}
231
const updatedItem = await this.toChatSessionItem(session, { includeChanges: true }, token);
232
controller.items.add(updatedItem);
233
};
234
}
235
this._register(this.sessionService.onDidDeleteSession(async (e) => {
236
controller.items.delete(SessionIdForCLI.getResource(e));
237
}));
238
this._register(this.sessionService.onDidChangeSession(async (e) => {
239
// Push path: VS Code uses the item we provide as source of truth and does not
240
// re-invoke `resolveChatSessionItem` for already-visible rows. Include changes
241
// eagerly so the visible row reflects the latest diff info.
242
const item = await this.toChatSessionItem(e, { includeChanges: true });
243
controller.items.add(item);
244
}));
245
this._register(this.sessionService.onDidCreateSession(async (e) => {
246
const resource = SessionIdForCLI.getResource(e.id);
247
if (controller.items.get(resource)) {
248
return;
249
}
250
const item = await this.toChatSessionItem(e, { includeChanges: true });
251
controller.items.add(item);
252
}));
253
254
// Handle worktree cleanup/recreation when archive state changes
255
if (controller.onDidChangeChatSessionItemState) {
256
this._register(controller.onDidChangeChatSessionItemState(async (item) => {
257
const sessionId = SessionIdForCLI.parse(item.resource);
258
if (item.archived) {
259
try {
260
const result = await this.copilotCLIWorktreeManagerService.cleanupWorktreeOnArchive(sessionId);
261
this.logService.trace(`[CopilotCLI] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`);
262
} catch (error) {
263
this.logService.error(`[CopilotCLI] Failed to cleanup worktree for archived session ${sessionId}:`, error);
264
}
265
} else {
266
try {
267
const result = await this.copilotCLIWorktreeManagerService.recreateWorktreeOnUnarchive(sessionId);
268
this.logService.trace(`[CopilotCLI] Worktree recreation for session ${sessionId}: ${result.recreated ? 'recreated' : result.reason}`);
269
if (result.recreated) {
270
await this.refreshSession({ reason: 'update', sessionId });
271
}
272
} catch (error) {
273
this.logService.error(`[CopilotCLI] Failed to recreate worktree for unarchived session ${sessionId}:`, error);
274
}
275
}
276
}));
277
}
278
279
const newInputStates: WeakRef<vscode.ChatSessionInputState>[] = [];
280
controller.getChatSessionInputState = async (sessionResource, context, token) => {
281
const isExistingSession = sessionResource && !this.sessionService.isNewSessionId(SessionIdForCLI.parse(sessionResource));
282
if (isExistingSession) {
283
const groups = await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token);
284
return controller.createChatSessionInputState(groups);
285
} else {
286
const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState);
287
const state = controller.createChatSessionInputState(groups);
288
// Only wire dynamic updates for new sessions (existing sessions are fully locked).
289
// Note: don't use the getChatSessionInputState token here — it's a one-shot token
290
// that may be disposed by the time the user interacts with the dropdowns.
291
newInputStates.push(new WeakRef(state));
292
state.onDidChange(() => {
293
void this._optionGroupBuilder.handleInputStateChange(state);
294
});
295
return state;
296
}
297
};
298
299
// Refresh new-session dropdown groups when git or workspace state changes
300
// (e.g. after git init, opening a repo, or adding/removing workspace folders).
301
const refreshActiveInputState = () => {
302
// Sweep stale WeakRefs before iterating
303
for (let i = newInputStates.length - 1; i >= 0; i--) {
304
if (!newInputStates[i].deref()) {
305
newInputStates.splice(i, 1);
306
}
307
}
308
for (const weakRef of newInputStates) {
309
const state = weakRef.deref();
310
if (state) {
311
void this._optionGroupBuilder.rebuildInputState(state);
312
}
313
}
314
};
315
this._register(this._gitService.onDidFinishInitialization(refreshActiveInputState));
316
this._register(this._gitService.onDidOpenRepository(refreshActiveInputState));
317
this._register(this._gitService.onDidCloseRepository(refreshActiveInputState));
318
this._register(this._workspaceService.onDidChangeWorkspaceFolders(refreshActiveInputState));
319
}
320
321
public getAssociatedSessions(folder: Uri): string[] {
322
return this._metadataStore.getSessionIdsForFolder(folder);
323
}
324
325
public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise<void> {
326
await this._optionGroupBuilder.rebuildInputState(inputState, folderUri);
327
}
328
329
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void> {
330
if (refreshOptions.reason === 'delete') {
331
const uri = SessionIdForCLI.getResource(refreshOptions.sessionId);
332
this.controller.items.delete(uri);
333
} else if (refreshOptions.reason === 'update' && hasKey(refreshOptions, { 'sessionIds': true })) {
334
await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => {
335
const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None);
336
if (item) {
337
// Push path — include changes eagerly (see `onDidChangeSession`).
338
const chatSessionItem = await this.toChatSessionItem(item, { includeChanges: true });
339
this.controller.items.add(chatSessionItem);
340
}
341
}));
342
} else {
343
const item = await this.sessionService.getSessionItem(refreshOptions.sessionId, CancellationToken.None);
344
if (item) {
345
// Push path — include changes eagerly (see `onDidChangeSession`).
346
const chatSessionItem = await this.toChatSessionItem(item, { includeChanges: true });
347
this.controller.items.add(chatSessionItem);
348
}
349
}
350
}
351
352
public async toChatSessionItem(session: ICopilotCLISessionItem, options?: { readonly includeChanges?: boolean }, token?: vscode.CancellationToken): Promise<vscode.ChatSessionItem> {
353
token = token ?? CancellationToken.None;
354
const resource = SessionIdForCLI.getResource(session.id);
355
const item = this.controller.createChatSessionItem(resource, session.label);
356
357
let worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token);
358
const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath)
359
: session.workingDirectory;
360
if (token.isCancellationRequested) {
361
return item;
362
363
}
364
item.timing = session.timing;
365
item.status = session.status ?? vscode.ChatSessionStatus.Completed;
366
367
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
368
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
369
// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
370
if (options?.includeChanges || ((await this.hasCachedChanges(session.id, worktreeProperties)))) {
371
const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
372
if (token.isCancellationRequested) {
373
return item;
374
}
375
// We need to get an updated version of worktree properties here because when the
376
// changes are being computed, the worktree properties are also updated with the
377
// repository state which we are passing along through the metadata
378
worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token);
379
if (token.isCancellationRequested) {
380
return item;
381
}
382
383
item.changes = changes;
384
}
385
386
if (token.isCancellationRequested) {
387
return item;
388
}
389
390
const [badge, metadata] = await Promise.all([
391
this.buildBadge(worktreeProperties, workingDirectory),
392
this.buildMetadata(session.id, worktreeProperties, workingDirectory),
393
]);
394
item.badge = badge;
395
item.metadata = metadata;
396
return item;
397
}
398
399
private async buildBadge(
400
worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,
401
workingDirectory: vscode.Uri | undefined,
402
): Promise<vscode.MarkdownString | undefined> {
403
const repositories = this._gitService.repositories.filter(r => r.kind !== 'worktree');
404
const shouldShow = vscode.workspace.workspaceFolders === undefined ||
405
vscode.workspace.isAgentSessionsWorkspace ||
406
repositories.length > 1;
407
if (!shouldShow) {
408
return undefined;
409
}
410
const badgeUri = worktreeProperties?.repositoryPath
411
? vscode.Uri.file(worktreeProperties.repositoryPath)
412
: workingDirectory;
413
if (!badgeUri) {
414
return undefined;
415
}
416
const isTrusted = await vscode.workspace.isResourceTrusted(badgeUri);
417
const isRepo = !!worktreeProperties?.repositoryPath;
418
const icon = isTrusted ? (isRepo ? '$(repo)' : '$(folder)') : '$(workspace-untrusted)';
419
const badge = new vscode.MarkdownString(`${icon} ${basename(badgeUri)}`);
420
badge.supportThemeIcons = true;
421
return badge;
422
}
423
424
private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
425
if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
426
return true;
427
}
428
const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
429
this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId),
430
this._workspaceFolderService.hasCachedChanges(sessionId)
431
]);
432
return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;
433
}
434
435
private async buildChanges(
436
sessionId: string,
437
worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,
438
workingDirectory: vscode.Uri | undefined,
439
token: CancellationToken = CancellationToken.None
440
): Promise<vscode.ChatSessionChangedFile[]> {
441
const changes: vscode.ChatSessionChangedFile[] = [];
442
if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) {
443
if (token.isCancellationRequested) {
444
return [];
445
}
446
changes.push(...(await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeChanges(sessionId), token) ?? []));
447
} else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) {
448
if (token.isCancellationRequested) {
449
return [];
450
}
451
const workspaceChanges = await raceCancellation(this._workspaceFolderService.getWorkspaceChanges(sessionId), token) ?? [];
452
const repositoryProperties = await raceCancellation(this._metadataStore.getRepositoryProperties(sessionId), token);
453
454
changes.push(...workspaceChanges.map(change => {
455
const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD';
456
457
return new vscode.ChatSessionChangedFile(
458
vscode.Uri.file(change.filePath),
459
change.originalFilePath
460
? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef)
461
: undefined,
462
change.modifiedFilePath
463
? vscode.Uri.file(change.modifiedFilePath)
464
: undefined,
465
change.statistics.additions,
466
change.statistics.deletions);
467
}));
468
}
469
return changes;
470
}
471
472
private async buildMetadata(
473
sessionId: string,
474
worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,
475
workingDirectory: vscode.Uri | undefined,
476
): Promise<{ readonly [key: string]: unknown }> {
477
if (worktreeProperties) {
478
const sessionParentId = await this._metadataStore.getSessionParentId(sessionId);
479
480
return {
481
sessionParentId,
482
autoCommit: worktreeProperties.autoCommit !== false,
483
baseCommit: worktreeProperties?.baseCommit,
484
baseBranchName: worktreeProperties.version === 2
485
? worktreeProperties.baseBranchName
486
: undefined,
487
baseBranchProtected: worktreeProperties.version === 2
488
? worktreeProperties.baseBranchProtected === true
489
: undefined,
490
branchName: worktreeProperties?.branchName,
491
upstreamBranchName: worktreeProperties.version === 2
492
? worktreeProperties.upstreamBranchName
493
: undefined,
494
isolationMode: IsolationMode.Worktree,
495
repositoryPath: worktreeProperties?.repositoryPath,
496
worktreePath: worktreeProperties?.worktreePath,
497
pullRequestUrl: worktreeProperties.version === 2
498
? worktreeProperties.pullRequestUrl
499
: undefined,
500
pullRequestState: worktreeProperties.version === 2
501
? worktreeProperties.pullRequestState
502
: undefined,
503
firstCheckpointRef: worktreeProperties.version === 2
504
? worktreeProperties.firstCheckpointRef
505
: undefined,
506
baseCheckpointRef: worktreeProperties.version === 2
507
? worktreeProperties.baseCheckpointRef
508
: undefined,
509
lastCheckpointRef: worktreeProperties.version === 2
510
? worktreeProperties.lastCheckpointRef
511
: undefined,
512
hasGitHubRemote: worktreeProperties.version === 2
513
? worktreeProperties.hasGitHubRemote
514
: undefined,
515
incomingChanges: worktreeProperties.version === 2
516
? worktreeProperties.incomingChanges
517
: undefined,
518
outgoingChanges: worktreeProperties.version === 2
519
? worktreeProperties.outgoingChanges
520
: undefined,
521
uncommittedChanges: worktreeProperties.version === 2
522
? worktreeProperties.uncommittedChanges
523
: undefined
524
} satisfies { readonly [key: string]: unknown };
525
}
526
527
const [sessionParentId, sessionRequestDetails, repositoryProperties] = await Promise.all([
528
this._metadataStore.getSessionParentId(sessionId),
529
this._metadataStore.getRequestDetails(sessionId),
530
this._metadataStore.getRepositoryProperties(sessionId)
531
]);
532
533
let lastCheckpointRef: string | undefined;
534
for (let i = sessionRequestDetails.length - 1; i >= 0; i--) {
535
const checkpointRef = sessionRequestDetails[i]?.checkpointRef;
536
if (checkpointRef !== undefined) {
537
lastCheckpointRef = checkpointRef;
538
break;
539
}
540
}
541
542
const firstCheckpointRef = lastCheckpointRef
543
? `${lastCheckpointRef.slice(0, lastCheckpointRef.lastIndexOf('/'))}/0`
544
: undefined;
545
546
return {
547
sessionParentId,
548
isolationMode: IsolationMode.Workspace,
549
repositoryPath: repositoryProperties?.repositoryPath,
550
branchName: repositoryProperties?.branchName,
551
baseBranchName: repositoryProperties?.baseBranchName,
552
upstreamBranchName: repositoryProperties?.upstreamBranchName,
553
workingDirectoryPath: workingDirectory?.fsPath,
554
hasGitHubRemote: repositoryProperties?.hasGitHubRemote,
555
incomingChanges: repositoryProperties?.incomingChanges,
556
outgoingChanges: repositoryProperties?.outgoingChanges,
557
uncommittedChanges: repositoryProperties?.uncommittedChanges,
558
firstCheckpointRef,
559
lastCheckpointRef
560
} satisfies { readonly [key: string]: unknown };
561
}
562
563
async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken, context?: { readonly inputState: vscode.ChatSessionInputState }): Promise<vscode.ChatSession> {
564
const stopwatch = new StopWatch();
565
try {
566
const copilotcliSessionId = SessionIdForCLI.parse(resource);
567
if (copilotcliSessionId.startsWith('untitled:') || copilotcliSessionId.startsWith('untitled-')) {
568
return {
569
history: [],
570
requestHandler: undefined,
571
};
572
}
573
if (this.sessionService.isNewSessionId(copilotcliSessionId)) {
574
const session = this.newSessions.get(resource);
575
if (!session) {
576
throw new Error('Session not found');
577
}
578
579
const options: Record<string, vscode.ChatSessionProviderOptionItem> = {};
580
for (const group of (context?.inputState.groups || [])) {
581
if (group.selected) {
582
options[group.id] = { ...group.selected, locked: true };
583
}
584
}
585
586
return {
587
title: session.label,
588
history: [],
589
options,
590
requestHandler: undefined,
591
};
592
} else {
593
this.newSessions.delete(resource);
594
// Fire-and-forget: detect PR when the user opens a session.
595
this._prDetectionService.detectPullRequest(copilotcliSessionId);
596
597
const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);
598
const [history, title, optionGroups] = await Promise.all([
599
this.getSessionHistory(copilotcliSessionId, folderRepo, token),
600
this.sessionService.getSessionTitle(copilotcliSessionId, token),
601
this._optionGroupBuilder.buildExistingSessionInputStateGroups(resource, token),
602
]);
603
604
const options: Record<string, vscode.ChatSessionProviderOptionItem> = {};
605
for (const group of optionGroups) {
606
if (group.selected) {
607
options[group.id] = { ...group.selected, locked: true };
608
}
609
}
610
611
return {
612
title,
613
history,
614
options,
615
requestHandler: undefined,
616
};
617
}
618
} finally {
619
this.logService.info(`[CopilotCLIChatSessionContentProvider] provideChatSessionContent for ${resource.toString()} took ${stopwatch.elapsed()}ms`);
620
}
621
}
622
623
private async getSessionHistory(sessionId: string, workspaceInfo: IWorkspaceInfo, token: vscode.CancellationToken) {
624
try {
625
_invalidCopilotCLISessionIdsWithErrorMessage.delete(sessionId);
626
const history = await this.sessionService.getChatHistory({ sessionId, workspace: workspaceInfo }, token);
627
return history;
628
} catch (error) {
629
if (!isUnknownEventTypeError(error)) {
630
throw error;
631
}
632
633
const partialHistory = await this.sessionService.tryGetPartialSessionHistory(sessionId);
634
if (partialHistory) {
635
_invalidCopilotCLISessionIdsWithErrorMessage.set(sessionId, error.message || String(error));
636
return partialHistory;
637
}
638
639
throw error;
640
}
641
}
642
643
}
644
645
export class CopilotCLIChatSessionParticipant extends Disposable {
646
647
constructor(
648
private readonly sessionItemProvider: ICopilotCLIChatSessionItemProvider,
649
private readonly promptResolver: CopilotCLIPromptResolver,
650
private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined,
651
private readonly branchNameGenerator: GitBranchNameGenerator | undefined,
652
@IGitService private readonly gitService: IGitService,
653
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
654
@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
655
@ITelemetryService private readonly telemetryService: ITelemetryService,
656
@ILogService private readonly logService: ILogService,
657
@IChatDelegationSummaryService private readonly chatDelegationSummaryService: IChatDelegationSummaryService,
658
@IConfigurationService private readonly configurationService: IConfigurationService,
659
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
660
@ICopilotCLIChatSessionInitializer private readonly sessionInitializer: ICopilotCLIChatSessionInitializer,
661
@ISessionRequestLifecycle private readonly sessionRequestLifecycle: ISessionRequestLifecycle,
662
@IPullRequestDetectionService private readonly prDetectionService: IPullRequestDetectionService,
663
@ISessionOptionGroupBuilder private readonly _optionGroupBuilder: ISessionOptionGroupBuilder,
664
) {
665
super();
666
667
this._register(this.prDetectionService.onDidDetectPullRequest(sessionId => {
668
this.sessionItemProvider.refreshSession({ reason: 'update', sessionId }).catch(error => this.logService.error(error, 'Failed to refresh session after PR detection'));
669
}));
670
}
671
672
createHandler(): ChatExtendedRequestHandler {
673
return this.handleRequest.bind(this);
674
}
675
676
/**
677
* Outer request handler that supports *yielding* for session steering.
678
*
679
* ## How steering works end-to-end
680
*
681
* 1. The user sends a message while the session is already processing a
682
* previous request (status is `InProgress` or `NeedsInput`).
683
* 2. VS Code signals this by setting `context.yieldRequested = true` on the
684
* *previous* request's context object.
685
* 3. This handler polls `context.yieldRequested` every 100 ms. Once detected
686
* the outer `Promise.race` resolves, returning control to VS Code so it
687
* can dispatch the new (steering) request.
688
* 4. Crucially, the inner `handleRequestImpl` promise is **not** cancelled
689
* or disposed – the original SDK session continues running in the
690
* background.
691
* 5. When the new request arrives, `handleRequest` on the underlying
692
* {@link CopilotCLISession} detects the session is still busy and routes
693
* through `_handleRequestSteering`, which sends the new prompt with
694
* `mode: 'immediate'` and waits for both the steering send and the
695
* original request to complete.
696
*/
697
private async handleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> {
698
const disposables = new DisposableStore();
699
try {
700
const handled = this.handleRequestImpl(request, context, stream, token);
701
const interval = disposables.add(new IntervalTimer());
702
const yielded = new DeferredPromise<void>();
703
interval.cancelAndSet(() => {
704
if (context.yieldRequested) {
705
yielded.complete();
706
}
707
}, CHECK_FOR_STEERING_DELAY);
708
709
return await Promise.race([yielded.p, handled]);
710
} finally {
711
disposables.dispose();
712
}
713
}
714
715
private sendTelemetryForHandleRequest(request: vscode.ChatRequest, context: vscode.ChatContext): void {
716
const { chatSessionContext } = context;
717
const hasChatSessionItem = String(!!chatSessionContext?.chatSessionItem);
718
const sessionId = chatSessionContext ? SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource) : undefined;
719
const isUntitled = sessionId ? String(this.sessionService.isNewSessionId(sessionId)) : 'false';
720
const hasDelegatePrompt = String(request.command === 'delegate');
721
722
/* __GDPR__
723
"copilotcli.chat.invoke" : {
724
"owner": "joshspicer",
725
"comment": "Event sent when a CopilotCLI chat request is made.",
726
"chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The unique chat request ID." },
727
"hasChatSessionItem": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Invoked with a chat session item." },
728
"isUntitled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the chat session is untitled." },
729
"hasDelegatePrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the prompt is a /delegate command." }
730
}
731
*/
732
this.telemetryService.sendMSFTTelemetryEvent('copilotcli.chat.invoke', {
733
chatRequestId: request.id,
734
hasChatSessionItem,
735
isUntitled,
736
hasDelegatePrompt
737
});
738
}
739
740
private async authenticate(): Promise<NonNullable<SessionOptions['authInfo']>> {
741
const authInfo = await this.copilotCLISDK.getAuthInfo().catch((ex) => this.logService.error(ex, 'Authorization failed'));
742
if (!authInfo) {
743
this.logService.error(`Authorization failed`);
744
throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));
745
}
746
if ((authInfo.type === 'token' && !authInfo.token) && !this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl)) {
747
this.logService.error(`Authorization failed`);
748
throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));
749
}
750
return authInfo;
751
}
752
753
/**
754
* Resolve the input and attachments for the SDK session based on request type.
755
*
756
* The VS Code chat API creates the session before firing the request handler,
757
* so delegated or remotely-steered requests pre-resolve and cache their prompt metadata
758
* before the handler runs.
759
*/
760
private async resolveInput(
761
request: vscode.ChatRequest,
762
session: ICopilotCLISession,
763
isNewSession: boolean,
764
token: vscode.CancellationToken,
765
): Promise<{ input: { prompt: string; command?: CopilotCLICommand; source?: SendOptions['source'] }; attachments: Attachment[] }> {
766
const contextForRequest = takePendingCopilotCLIRequestContext(session.sessionId);
767
768
if (contextForRequest) {
769
return { input: { prompt: contextForRequest.prompt, source: contextForRequest.source }, attachments: contextForRequest.attachments };
770
}
771
772
if (request.command && !request.prompt && !isNewSession) {
773
const input = (copilotCLICommands as readonly string[]).includes(request.command)
774
? { command: request.command as CopilotCLICommand, prompt: '' }
775
: { prompt: `/${request.command}` };
776
return { input, attachments: [] };
777
}
778
779
const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.workspace, [], token);
780
const input = (request.command && (copilotCLICommands as readonly string[]).includes(request.command))
781
? { command: request.command as CopilotCLICommand, prompt }
782
: { prompt };
783
return { input, attachments };
784
}
785
786
private generateNewBranchName(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise<string | undefined> {
787
const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined);
788
const fakeContext: vscode.ChatContext = {
789
history: [requestTurn],
790
yieldRequested: false,
791
};
792
const branchNamePromise = (request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined);
793
return branchNamePromise;
794
}
795
private async handleRequestImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> {
796
const { chatSessionContext } = context;
797
const disposables = new DisposableStore();
798
let sdkSessionId: string | undefined = undefined;
799
let session: IReference<ICopilotCLISession> | undefined = undefined;
800
try {
801
this.sendTelemetryForHandleRequest(request, context);
802
803
const authInfo = await this.authenticate();
804
805
if (!chatSessionContext || !SessionIdForCLI.isCLIResource(request.sessionResource)) {
806
return await this.handleDelegationFromAnotherChat(request, undefined, request.references, context, stream, authInfo, token);
807
}
808
809
const { resource } = chatSessionContext.chatSessionItem;
810
const sessionId = SessionIdForCLI.parse(resource);
811
const isNewSession = this.sessionService.isNewSessionId(sessionId);
812
const invalidSessionMessage = _invalidCopilotCLISessionIdsWithErrorMessage.get(sessionId);
813
814
if (invalidSessionMessage) {
815
const { issueUrl } = getSessionLoadFailureIssueInfo(invalidSessionMessage);
816
const warningMessage = new vscode.MarkdownString();
817
warningMessage.appendMarkdown(l10n.t({
818
message: "Failed loading this session. If this issue persists, please [report an issue]({issueUrl}). \nError: ",
819
args: { issueUrl },
820
comment: [`{Locked=']({'}`]
821
}));
822
warningMessage.appendText(invalidSessionMessage);
823
stream.warning(warningMessage);
824
return {};
825
}
826
827
const branchNamePromise = isNewSession ? this.generateNewBranchName(request, token) : Promise.resolve(undefined);
828
829
if (isNewSession) {
830
this._optionGroupBuilder.lockInputStateGroups(chatSessionContext.inputState);
831
}
832
833
const selectedOptions = getSelectedSessionOptions(chatSessionContext.inputState);
834
const sessionResult = await this.getOrCreateSession(request, chatSessionContext.chatSessionItem.resource, { ...selectedOptions, newBranch: branchNamePromise, stream }, disposables, token);
835
({ session } = sessionResult);
836
837
838
if (isNewSession && !sessionResult.trusted) {
839
await this._optionGroupBuilder.rebuildInputState(chatSessionContext.inputState);
840
}
841
842
const { model, agent } = sessionResult;
843
if (!session || token.isCancellationRequested) {
844
return {};
845
}
846
847
if (isNewSession && session.object.workspace.worktreeProperties) {
848
const branchName = session.object.workspace.worktreeProperties.branchName;
849
this._optionGroupBuilder.updateBranchInInputState(chatSessionContext.inputState, branchName);
850
}
851
852
sdkSessionId = session.object.sessionId;
853
854
await this.sessionRequestLifecycle.startRequest(sdkSessionId, request, context.history.length === 0, session.object.workspace, agent?.name);
855
856
if (request.command === 'delegate') {
857
await this.handleDelegationToCloud(session.object, request, context, stream, token);
858
} else {
859
const { input, attachments } = await this.resolveInput(request, session.object, isNewSession, token);
860
await session.object.handleRequest(request, input, attachments, model, authInfo, token);
861
}
862
863
return {};
864
} catch (ex) {
865
if (isCancellationError(ex)) {
866
return {};
867
}
868
throw ex;
869
} finally {
870
if (sdkSessionId && session) {
871
await this.sessionRequestLifecycle.endRequest(
872
sdkSessionId, request,
873
{ status: session.object.status, workspace: session.object.workspace, createdPullRequestUrl: session.object.createdPullRequestUrl },
874
token,
875
);
876
this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: sdkSessionId })
877
.catch(error => this.logService.error(error, 'Failed to refresh session item after handling request'));
878
}
879
disposables.dispose();
880
}
881
}
882
883
private async getOrCreateSession(request: vscode.ChatRequest, chatResource: vscode.Uri, options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }> {
884
const result = await this.sessionInitializer.getOrCreateSession(request, chatResource, options, disposables, token);
885
const { session, isNewSession, model, agent, trusted } = result;
886
if (!session || token.isCancellationRequested) {
887
return { session: undefined, isNewSession, model, agent, trusted };
888
}
889
890
if (isNewSession) {
891
this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: session.object.sessionId });
892
}
893
894
return { session, isNewSession, model, agent, trusted };
895
}
896
897
private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {
898
if (!this.cloudSessionProvider) {
899
stream.warning(l10n.t('No cloud agent available'));
900
return;
901
}
902
903
// Check for uncommitted changes
904
const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.sessionId);
905
const repositoryPath = worktreeProperties?.repositoryPath ? Uri.file(worktreeProperties.repositoryPath) : getWorkingDirectory(session.workspace);
906
const repository = repositoryPath ? await this.gitService.getRepository(repositoryPath) : undefined;
907
const hasChanges = (repository?.changes?.indexChanges && repository.changes.indexChanges.length > 0);
908
909
if (hasChanges) {
910
stream.warning(l10n.t('You have uncommitted changes in your workspace. The cloud agent will start from the last committed state. Consider committing your changes first if you want to include them.'));
911
}
912
913
const prInfo = await this.cloudSessionProvider.delegate(request, stream, context, token, { prompt: request.prompt, chatContext: context });
914
await this.recordPushToSession(session, `/delegate ${request.prompt}`, prInfo);
915
916
}
917
918
private async handleDelegationFromAnotherChat(
919
request: vscode.ChatRequest,
920
userPrompt: string | undefined,
921
otherReferences: readonly vscode.ChatPromptReference[] | undefined,
922
context: vscode.ChatContext,
923
stream: vscode.ChatResponseStream,
924
authInfo: NonNullable<SessionOptions['authInfo']>,
925
token: vscode.CancellationToken
926
): Promise<vscode.ChatResult> {
927
let summary: string | undefined;
928
const requestPromptPromise = (async () => {
929
if (this.hasHistoryToSummarize(context.history)) {
930
stream.progress(l10n.t('Analyzing chat history'));
931
summary = await this.chatDelegationSummaryService.summarize(context, token);
932
summary = summary ? `**Summary**\n${summary}` : undefined;
933
}
934
935
// Give priority to userPrompt if provided (e.g., from confirmation metadata)
936
userPrompt = userPrompt || request.prompt;
937
return summary ? `${userPrompt}\n${summary}` : userPrompt;
938
})();
939
const branchNamePromise = this.generateNewBranchName(request, token);
940
const { workspaceInfo, cancelled } = await this.sessionInitializer.initializeWorkingDirectory(undefined, { stream, isolation: IsolationMode.Worktree, newBranch: branchNamePromise }, request.toolInvocationToken, token);
941
942
if (cancelled || token.isCancellationRequested) {
943
stream.markdown(l10n.t('Copilot CLI delegation cancelled.'));
944
return {};
945
}
946
const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token);
947
948
const mcpServerMappings = buildMcpServerMappings(request.tools);
949
const session = await this.sessionInitializer.createDelegatedSession(request, workspaceInfo, { mcpServerMappings }, token);
950
951
if (summary) {
952
const summaryRef = await this.chatDelegationSummaryService.trackSummaryUsage(session.object.sessionId, summary);
953
if (summaryRef) {
954
references.push(summaryRef);
955
}
956
}
957
958
setPendingCopilotCLIRequestContext(session.object.sessionId, { prompt, attachments });
959
void vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
960
resource: SessionIdForCLI.getResource(session.object.sessionId),
961
prompt: userPrompt || request.prompt,
962
attachedContext: references.map(ref => convertReferenceToVariable(ref, attachments))
963
}).then(undefined, error => {
964
clearPendingCopilotCLIRequestContext(session.object.sessionId);
965
this.logService.error(error, '[CopilotCLIChatSessionContentProvider] Failed to open Copilot CLI session');
966
});
967
968
stream.markdown(l10n.t('A Copilot CLI session has begun working on your request. Follow its progress in the sessions list.'));
969
970
return {};
971
}
972
973
private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean {
974
if (!history || history.length === 0) {
975
return false;
976
}
977
const allResponsesEmpty = history.every(turn => {
978
if (turn instanceof vscode.ChatResponseTurn) {
979
return turn.response.length === 0;
980
}
981
return true;
982
});
983
return !allResponsesEmpty;
984
}
985
986
private async recordPushToSession(
987
session: ICopilotCLISession,
988
userPrompt: string,
989
prInfo: vscode.ChatResponsePullRequestPart
990
): Promise<void> {
991
// Add user message event
992
session.addUserMessage(userPrompt);
993
994
// Add assistant message event with embedded PR metadata
995
const assistantMessage = `A cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.\n<pr_metadata uri="${prInfo.uri?.toString()}" title="${escapeXml(prInfo.title)}" description="${escapeXml(prInfo.description)}" author="${escapeXml(prInfo.author)}" linkTag="${escapeXml(prInfo.linkTag)}"/>`;
996
session.addUserAssistantMessage(assistantMessage);
997
}
998
}
999
1000
export function registerCLIChatCommands(
1001
copilotCLISessionService: ICopilotCLISessionService,
1002
copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
1003
gitService: IGitService,
1004
copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService,
1005
contentProvider: CopilotCLIChatSessionContentProvider,
1006
folderRepositoryManager: IFolderRepositoryManager,
1007
copilotCLIFolderMruService: IChatFolderMruService,
1008
envService: INativeEnvService,
1009
fileSystemService: IFileSystemService,
1010
sessionTracker: ICopilotCLISessionTracker,
1011
terminalIntegration: ICopilotCLITerminalIntegration,
1012
logService: ILogService
1013
): IDisposable {
1014
const disposableStore = new DisposableStore();
1015
1016
async function deleteSessionById(id: string): Promise<void> {
1017
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(id);
1018
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
1019
1020
await copilotCLISessionService.deleteSession(id);
1021
await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(id);
1022
1023
if (worktreePath) {
1024
const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false);
1025
if (worktreeExists) {
1026
try {
1027
const repository = worktree ? await gitService.getRepository(vscode.Uri.file(worktree.repositoryPath), true) : undefined;
1028
if (!repository) {
1029
throw new Error(l10n.t('No active repository found to delete worktree.'));
1030
}
1031
await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath);
1032
} catch (error) {
1033
vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error)));
1034
}
1035
}
1036
}
1037
1038
await contentProvider.refreshSession({ reason: 'delete', sessionId: id });
1039
}
1040
1041
// Terminal integration setup: resolve session dirs for terminal links.
1042
disposableStore.add(terminalIntegration);
1043
terminalIntegration.setSessionDirResolver(terminal =>
1044
resolveSessionDirsForTerminal(sessionTracker, terminal)
1045
);
1046
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.delete', async (sessionItem?: vscode.ChatSessionItem) => {
1047
if (sessionItem?.resource) {
1048
const id = SessionIdForCLI.parse(sessionItem.resource);
1049
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
1050
1051
const confirmMessage = worktreePath
1052
? l10n.t('Are you sure you want to delete the session and its associated worktree?')
1053
: l10n.t('Are you sure you want to delete the session?');
1054
1055
const deleteLabel = l10n.t('Delete');
1056
const result = await vscode.window.showWarningMessage(
1057
confirmMessage,
1058
{ modal: true },
1059
deleteLabel
1060
);
1061
1062
if (result === deleteLabel) {
1063
await deleteSessionById(id);
1064
}
1065
}
1066
}));
1067
disposableStore.add(vscode.commands.registerCommand('agents.github.copilot.cli.deleteSessions', async (sessionItems?: vscode.ChatSessionItem[], options?: { skipConfirmation?: boolean }) => {
1068
if (!sessionItems?.length) {
1069
return;
1070
}
1071
1072
if (!options?.skipConfirmation) {
1073
const deleteLabel = l10n.t('Delete');
1074
const confirmMessage = sessionItems.length === 1
1075
? l10n.t('Are you sure you want to delete the session?')
1076
: l10n.t('Are you sure you want to delete {0} sessions?', sessionItems.length);
1077
const result = await vscode.window.showWarningMessage(
1078
confirmMessage,
1079
{ modal: true },
1080
deleteLabel
1081
);
1082
if (result !== deleteLabel) {
1083
return;
1084
}
1085
}
1086
1087
for (const sessionItem of sessionItems) {
1088
if (sessionItem.resource) {
1089
const id = SessionIdForCLI.parse(sessionItem.resource);
1090
await deleteSessionById(id);
1091
}
1092
}
1093
}));
1094
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.resumeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => {
1095
if (sessionItem?.resource) {
1096
const id = SessionIdForCLI.parse(sessionItem.resource);
1097
const existingTerminal = await sessionTracker.getTerminal(id);
1098
if (existingTerminal) {
1099
existingTerminal.show();
1100
return;
1101
}
1102
1103
const terminalName = sessionItem.label || id;
1104
const cliArgs = ['--resume', id];
1105
const token = new vscode.CancellationTokenSource();
1106
try {
1107
const folderInfo = await folderRepositoryManager.getFolderRepository(id, undefined, token.token);
1108
const cwd = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;
1109
const terminal = await terminalIntegration.openTerminal(terminalName, cliArgs, cwd?.fsPath);
1110
if (terminal) {
1111
sessionTracker.setSessionTerminal(id, terminal);
1112
terminalIntegration.setTerminalSessionDir(terminal, Uri.file(getCopilotCLISessionDir(id)));
1113
}
1114
} finally {
1115
token.dispose();
1116
}
1117
}
1118
}));
1119
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.rename', async (sessionItem?: vscode.ChatSessionItem) => {
1120
if (!sessionItem?.resource) {
1121
return;
1122
}
1123
const id = SessionIdForCLI.parse(sessionItem.resource);
1124
const newTitle = await vscode.window.showInputBox({
1125
prompt: l10n.t('New agent session title'),
1126
value: sessionItem.label,
1127
validateInput: value => {
1128
if (!value.trim()) {
1129
return l10n.t('Title cannot be empty');
1130
}
1131
return undefined;
1132
}
1133
});
1134
if (newTitle) {
1135
const trimmedTitle = newTitle.trim();
1136
if (trimmedTitle) {
1137
await copilotCLISessionService.renameSession(id, trimmedTitle);
1138
await contentProvider.refreshSession({ reason: 'update', sessionId: id });
1139
}
1140
}
1141
}));
1142
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.setTitle', async (sessionItem?: vscode.ChatSessionItem, title?: string) => {
1143
if (!sessionItem?.resource || !title) {
1144
return;
1145
}
1146
const trimmedTitle = title.trim();
1147
if (trimmedTitle) {
1148
const id = SessionIdForCLI.parse(sessionItem.resource);
1149
await copilotCLISessionService.renameSession(id, trimmedTitle);
1150
await contentProvider.refreshSession({ reason: 'update', sessionId: id });
1151
}
1152
}));
1153
1154
const createCopilotCLITerminal = async (location: TerminalOpenLocation = 'editor', name?: string, cwd?: string): Promise<void> => {
1155
// TODO@rebornix should be set by CLI
1156
const terminalName = name || process.env.COPILOTCLI_TERMINAL_TITLE || l10n.t('Copilot CLI');
1157
await terminalIntegration.openTerminal(terminalName, [], cwd, location);
1158
};
1159
1160
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.newSession', async () => {
1161
await createCopilotCLITerminal('editor', l10n.t('Copilot CLI'));
1162
}));
1163
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.newSessionToSide', async () => {
1164
await createCopilotCLITerminal('editorBeside', l10n.t('Copilot CLI'));
1165
}));
1166
disposableStore.add(vscode.commands.registerCommand(OPEN_IN_COPILOT_CLI_COMMAND_ID, async (sourceControlContext?: unknown) => {
1167
const rootUri = getSourceControlRootUri(sourceControlContext);
1168
await createCopilotCLITerminal('editor', l10n.t('Copilot CLI'), rootUri?.fsPath);
1169
}));
1170
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.openWorktreeInNewWindow', async (sessionItem?: vscode.ChatSessionItem) => {
1171
if (!sessionItem?.resource) {
1172
return;
1173
}
1174
1175
const id = SessionIdForCLI.parse(sessionItem.resource);
1176
const folderInfo = await folderRepositoryManager.getFolderRepository(id, undefined, CancellationToken.None);
1177
const folder = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;
1178
if (folder) {
1179
await vscode.commands.executeCommand('vscode.openFolder', folder, { forceNewWindow: true });
1180
}
1181
}));
1182
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.openWorktreeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => {
1183
if (!sessionItem?.resource) {
1184
return;
1185
}
1186
1187
const id = SessionIdForCLI.parse(sessionItem.resource);
1188
const folderInfo = await folderRepositoryManager.getFolderRepository(id, undefined, CancellationToken.None);
1189
const folder = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;
1190
if (folder) {
1191
vscode.window.createTerminal({ cwd: folder }).show();
1192
}
1193
}));
1194
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.copyWorktreeBranchName', async (sessionItem?: vscode.ChatSessionItem) => {
1195
if (!sessionItem?.resource) {
1196
return;
1197
}
1198
1199
const id = SessionIdForCLI.parse(sessionItem.resource);
1200
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(id);
1201
if (worktreeProperties?.branchName) {
1202
await vscode.env.clipboard.writeText(worktreeProperties.branchName);
1203
}
1204
}));
1205
async function selectFolder() {
1206
// Open folder picker dialog
1207
const folderUris = await vscode.window.showOpenDialog({
1208
canSelectFiles: false,
1209
canSelectFolders: true,
1210
canSelectMany: false,
1211
openLabel: l10n.t('Open Folder...'),
1212
});
1213
1214
return folderUris && folderUris.length > 0 ? folderUris[0] : undefined;
1215
}
1216
1217
function getSourceControlRootUri(sourceControlContext?: unknown): vscode.Uri | undefined {
1218
if (!sourceControlContext) {
1219
return undefined;
1220
}
1221
1222
if (Array.isArray(sourceControlContext)) {
1223
return getSourceControlRootUri(sourceControlContext[0]);
1224
}
1225
1226
if (isUri(sourceControlContext)) {
1227
return sourceControlContext;
1228
}
1229
1230
if (typeof sourceControlContext !== 'object') {
1231
return undefined;
1232
}
1233
1234
const candidate = sourceControlContext as {
1235
rootUri?: unknown;
1236
sourceControl?: { rootUri?: unknown };
1237
repository?: { rootUri?: unknown };
1238
};
1239
1240
if (isUri(candidate.rootUri)) {
1241
return candidate.rootUri;
1242
}
1243
1244
if (isUri(candidate.sourceControl?.rootUri)) {
1245
return candidate.sourceControl.rootUri;
1246
}
1247
1248
if (isUri(candidate.repository?.rootUri)) {
1249
return candidate.repository.rootUri;
1250
}
1251
1252
return undefined;
1253
}
1254
1255
// Command handler receives `{ inputState, sessionResource }` context args (new API)
1256
disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async ({ inputState }: { inputState: vscode.ChatSessionInputState; sessionResource: vscode.Uri | undefined }) => {
1257
let selectedFolderUri: Uri | undefined = undefined;
1258
const mruItems = await copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None);
1259
1260
if (mruItems.length === 0) {
1261
selectedFolderUri = await selectFolder();
1262
} else {
1263
type RecentFolderQuickPickItem = vscode.QuickPickItem & ({ folderUri: vscode.Uri; openFolder: false } | { folderUri: undefined; openFolder: true });
1264
const items: RecentFolderQuickPickItem[] = mruItems
1265
.map(item => {
1266
const optionItem = item.repository
1267
? toRepositoryOptionItem(item.folder)
1268
: toWorkspaceFolderOptionItem(item.folder, basename(item.folder));
1269
1270
return {
1271
label: optionItem.name,
1272
description: `~/${relative(envService.userHome.fsPath, item.folder.fsPath)}`,
1273
iconPath: optionItem.icon,
1274
folderUri: item.folder,
1275
openFolder: false
1276
};
1277
});
1278
1279
items.unshift({
1280
label: l10n.t('Open Folder...'),
1281
iconPath: new vscode.ThemeIcon('folder-opened'),
1282
folderUri: undefined,
1283
openFolder: true
1284
}, {
1285
kind: vscode.QuickPickItemKind.Separator,
1286
label: '',
1287
folderUri: undefined,
1288
openFolder: true
1289
});
1290
1291
const selectedFolder = new DeferredPromise<Uri | undefined>();
1292
const disposables = new DisposableStore();
1293
const quickPick = disposables.add(vscode.window.createQuickPick<RecentFolderQuickPickItem>());
1294
quickPick.items = items;
1295
quickPick.placeholder = l10n.t('Select a recent folder');
1296
quickPick.matchOnDescription = true;
1297
quickPick.ignoreFocusOut = true;
1298
quickPick.matchOnDetail = true;
1299
quickPick.show();
1300
disposables.add(quickPick.onDidHide(() => {
1301
selectedFolder.complete(undefined);
1302
}));
1303
disposables.add(quickPick.onDidAccept(async () => {
1304
if (quickPick.selectedItems.length === 0 && !quickPick.value) {
1305
selectedFolder.complete(undefined);
1306
quickPick.hide();
1307
} else if (quickPick.selectedItems.length && quickPick.selectedItems[0].folderUri) {
1308
selectedFolder.complete(quickPick.selectedItems[0].folderUri);
1309
quickPick.hide();
1310
} else if (quickPick.selectedItems.length && quickPick.selectedItems[0].openFolder) {
1311
selectedFolder.complete(await selectFolder());
1312
quickPick.hide();
1313
} else if (quickPick.value) {
1314
const fileOrFolder = vscode.Uri.file(quickPick.value);
1315
try {
1316
const stat = await vscode.workspace.fs.stat(fileOrFolder);
1317
let directory: Uri | undefined = undefined;
1318
if (stat.type & vscode.FileType.Directory) {
1319
quickPick.hide();
1320
directory = fileOrFolder;
1321
} else if (stat.type & vscode.FileType.File) {
1322
directory = dirname(fileOrFolder);
1323
}
1324
if (directory) {
1325
// Possible user selected a folder thats inside an existing workspace folder.
1326
selectedFolder.complete(vscode.workspace.getWorkspaceFolder(directory)?.uri || directory);
1327
quickPick.hide();
1328
}
1329
} catch {
1330
// ignore
1331
}
1332
}
1333
}));
1334
selectedFolderUri = await selectedFolder.p;
1335
disposables.dispose();
1336
}
1337
1338
if (!selectedFolderUri) {
1339
return;
1340
}
1341
if (!(await checkPathExists(selectedFolderUri, fileSystemService))) {
1342
await copilotCLIFolderMruService.deleteRecentlyUsedFolder(selectedFolderUri);
1343
const message = l10n.t('The path \'{0}\' does not exist on this computer.', selectedFolderUri.fsPath);
1344
vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message });
1345
return;
1346
}
1347
1348
// First check if user trusts the folder.
1349
const trusted = await vscode.workspace.requestResourceTrust({
1350
uri: selectedFolderUri,
1351
message: UNTRUSTED_FOLDER_MESSAGE
1352
});
1353
if (!trusted) {
1354
return;
1355
}
1356
1357
1358
// Update inputState groups with newly selected folder and reload branches
1359
if (inputState) {
1360
await contentProvider.updateInputStateAfterFolderSelection(inputState, selectedFolderUri);
1361
}
1362
}));
1363
1364
const applyChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1365
const resource = isUri(sessionItemOrResource)
1366
? sessionItemOrResource
1367
: sessionItemOrResource?.resource;
1368
1369
if (!resource) {
1370
return;
1371
}
1372
1373
try {
1374
// Apply changes
1375
const sessionId = SessionIdForCLI.parse(resource);
1376
await copilotCLIWorktreeManagerService.applyWorktreeChanges(sessionId);
1377
1378
// Close the multi-file diff editor if it's open
1379
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1380
const worktreePath = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : undefined;
1381
1382
if (worktreePath) {
1383
// Select the tabs to close
1384
const multiDiffTabToClose = vscode.window.tabGroups.all.flatMap(g => g.tabs)
1385
.filter(({ input }) => input instanceof vscode.TabInputTextMultiDiff && input.textDiffs.some(input =>
1386
extUri.isEqualOrParent(vscode.Uri.file(input.original.fsPath), worktreePath, true) ||
1387
extUri.isEqualOrParent(vscode.Uri.file(input.modified.fsPath), worktreePath, true)));
1388
1389
if (multiDiffTabToClose.length > 0) {
1390
// Close the tabs
1391
await vscode.window.tabGroups.close(multiDiffTabToClose, true);
1392
}
1393
}
1394
1395
// Pick up new git state
1396
await contentProvider.refreshSession({ reason: 'update', sessionId });
1397
} catch (error) {
1398
vscode.window.showErrorMessage(l10n.t('Failed to apply changes to the current workspace. Please stage or commit your changes in the current workspace and try again.'), { modal: true });
1399
}
1400
};
1401
1402
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.applyCopilotCLIAgentSessionChanges', applyChanges));
1403
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.applyCopilotCLIAgentSessionChanges.apply', applyChanges));
1404
1405
const mergeChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri, syncWithRemote: boolean = false) => {
1406
const resource = sessionItemOrResource instanceof vscode.Uri
1407
? sessionItemOrResource
1408
: sessionItemOrResource?.resource;
1409
1410
if (!resource) {
1411
return;
1412
}
1413
1414
let branchName: string | undefined;
1415
let worktreePath: string | undefined;
1416
let baseBranchName: string | undefined;
1417
let baseWorktreePath: string | undefined;
1418
1419
try {
1420
const sessionId = SessionIdForCLI.parse(resource);
1421
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1422
if (!worktreeProperties || worktreeProperties.version !== 2) {
1423
vscode.window.showErrorMessage(l10n.t('Merging changes is only supported for worktree-based sessions.'));
1424
return;
1425
}
1426
1427
branchName = worktreeProperties.branchName;
1428
worktreePath = worktreeProperties.worktreePath;
1429
baseBranchName = worktreeProperties.baseBranchName;
1430
baseWorktreePath = worktreeProperties.repositoryPath;
1431
} catch (error) {
1432
logService.error(`Failed to check worktree properties for merge changes: ${error instanceof Error ? error.message : String(error)}`);
1433
return;
1434
}
1435
1436
const contextValueSegments: string[] = [];
1437
contextValueSegments.push(`source branch name: ${branchName}`);
1438
contextValueSegments.push(`source worktree path: ${worktreePath}`);
1439
contextValueSegments.push(`target branch name: ${baseBranchName}`);
1440
contextValueSegments.push(`target worktree path: ${baseWorktreePath}`);
1441
1442
const prompt = syncWithRemote
1443
? `${builtinSlashSCommands.merge} and ${builtinSlashSCommands.sync}`
1444
: builtinSlashSCommands.merge;
1445
1446
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1447
resource,
1448
prompt,
1449
attachedContext: [{
1450
id: 'git-merge-changes',
1451
value: contextValueSegments.join('\n'),
1452
icon: new vscode.ThemeIcon('git-merge'),
1453
fullName: `${branchName} → ${baseBranchName}`,
1454
kind: 'generic'
1455
}]
1456
});
1457
};
1458
1459
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1460
await mergeChanges(sessionItemOrResource);
1461
}));
1462
1463
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.mergeCopilotCLIAgentSessionChanges.mergeAndSync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1464
await mergeChanges(sessionItemOrResource, true);
1465
}));
1466
1467
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.refreshChanges', async (resource?: vscode.Uri) => {
1468
if (!resource) {
1469
return;
1470
}
1471
1472
const sessionId = SessionIdForCLI.parse(resource);
1473
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1474
const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);
1475
1476
if (!worktreeProperties && !workspaceFolder) {
1477
return;
1478
}
1479
1480
if (worktreeProperties) {
1481
// Worktree
1482
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, {
1483
...worktreeProperties,
1484
changes: undefined
1485
});
1486
} else if (workspaceFolder) {
1487
// Workspace
1488
copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId);
1489
}
1490
1491
await contentProvider.refreshSession({ reason: 'update', sessionId });
1492
}));
1493
1494
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.initializeRepository', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1495
const resource = sessionItemOrResource instanceof vscode.Uri
1496
? sessionItemOrResource
1497
: sessionItemOrResource?.resource;
1498
1499
if (!resource) {
1500
return;
1501
}
1502
1503
const sessionId = SessionIdForCLI.parse(resource);
1504
const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);
1505
if (!workspaceFolder) {
1506
return;
1507
}
1508
1509
const repository = await gitService.initRepository(workspaceFolder);
1510
if (!repository) {
1511
return;
1512
}
1513
1514
const repositoryProperties = repository.state.HEAD?.name
1515
? {
1516
repositoryPath: repository.rootUri.fsPath,
1517
branchName: repository.state.HEAD.name
1518
} satisfies RepositoryProperties
1519
: undefined;
1520
1521
await copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repositoryProperties);
1522
copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId);
1523
1524
await contentProvider.refreshSession({ reason: 'update', sessionId });
1525
}));
1526
1527
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commit', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1528
const resource = sessionItemOrResource instanceof vscode.Uri
1529
? sessionItemOrResource
1530
: sessionItemOrResource?.resource;
1531
1532
if (!resource) {
1533
return;
1534
}
1535
1536
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1537
resource,
1538
prompt: builtinSlashSCommands.commit,
1539
});
1540
}));
1541
1542
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commitAndSync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1543
const resource = sessionItemOrResource instanceof vscode.Uri
1544
? sessionItemOrResource
1545
: sessionItemOrResource?.resource;
1546
1547
if (!resource) {
1548
return;
1549
}
1550
1551
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1552
resource,
1553
prompt: `${builtinSlashSCommands.commit} and ${builtinSlashSCommands.sync}`,
1554
});
1555
}));
1556
1557
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.sync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1558
const resource = sessionItemOrResource instanceof vscode.Uri
1559
? sessionItemOrResource
1560
: sessionItemOrResource?.resource;
1561
1562
if (!resource) {
1563
return;
1564
}
1565
1566
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1567
resource,
1568
prompt: builtinSlashSCommands.sync,
1569
});
1570
}));
1571
1572
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.discardChanges', async (sessionResource: vscode.Uri, ref: string, ...resources: vscode.Uri[]) => {
1573
if (!isUri(sessionResource) || !ref || resources.length === 0 || resources.some(r => !isUri(r))) {
1574
return;
1575
}
1576
1577
const sessionId = SessionIdForCLI.parse(sessionResource);
1578
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1579
const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);
1580
1581
const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder;
1582
const repository = repositoryUri ? await gitService.getRepository(repositoryUri) : undefined;
1583
if (!repository) {
1584
return;
1585
}
1586
1587
const confirmAction = l10n.t('Discard Changes');
1588
const message = resources.length === 1
1589
? l10n.t('Are you sure you want to discard the changes in \'{0}\'? This action cannot be undone.', basename(resources[0]))
1590
: l10n.t('Are you sure you want to discard the changes in these {0} files? This action cannot be undone.', resources.length);
1591
1592
const choice = await vscode.window.showWarningMessage(message, { modal: true }, confirmAction);
1593
if (choice !== confirmAction) {
1594
return;
1595
}
1596
1597
await gitService.restore(repository.rootUri, resources.map(r => r.fsPath), { ref });
1598
}));
1599
1600
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1601
const resource = sessionItemOrResource instanceof vscode.Uri
1602
? sessionItemOrResource
1603
: sessionItemOrResource?.resource;
1604
1605
if (!resource) {
1606
return;
1607
}
1608
1609
try {
1610
const sessionId = SessionIdForCLI.parse(resource);
1611
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1612
if (!worktreeProperties || worktreeProperties.version !== 2) {
1613
vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.'));
1614
return;
1615
}
1616
} catch (error) {
1617
logService.error(`Failed to check worktree properties for createPR: ${error instanceof Error ? error.message : String(error)}`);
1618
return;
1619
}
1620
1621
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1622
resource,
1623
prompt: builtinSlashSCommands.createPr,
1624
});
1625
}));
1626
1627
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1628
const resource = sessionItemOrResource instanceof vscode.Uri
1629
? sessionItemOrResource
1630
: sessionItemOrResource?.resource;
1631
1632
if (!resource) {
1633
return;
1634
}
1635
1636
try {
1637
const sessionId = SessionIdForCLI.parse(resource);
1638
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1639
if (!worktreeProperties || worktreeProperties.version !== 2) {
1640
vscode.window.showErrorMessage(l10n.t('Creating a draft pull request is only supported for worktree-based sessions.'));
1641
return;
1642
}
1643
} catch (error) {
1644
logService.error(`Failed to check worktree properties for createDraftPR: ${error instanceof Error ? error.message : String(error)}`);
1645
return;
1646
}
1647
1648
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1649
resource,
1650
prompt: builtinSlashSCommands.createDraftPr,
1651
});
1652
}));
1653
1654
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
1655
const resource = sessionItemOrResource instanceof vscode.Uri
1656
? sessionItemOrResource
1657
: sessionItemOrResource?.resource;
1658
1659
if (!resource) {
1660
return;
1661
}
1662
1663
let pullRequestUrl: string | undefined = undefined;
1664
1665
try {
1666
const sessionId = SessionIdForCLI.parse(resource);
1667
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1668
if (!worktreeProperties || worktreeProperties.version !== 2) {
1669
vscode.window.showErrorMessage(l10n.t('Updating a pull request is only supported for worktree-based sessions.'));
1670
return;
1671
}
1672
1673
pullRequestUrl = worktreeProperties.pullRequestUrl;
1674
} catch (error) {
1675
logService.error(`Failed to check worktree properties for updatePR: ${error instanceof Error ? error.message : String(error)}`);
1676
return;
1677
}
1678
1679
if (!pullRequestUrl) {
1680
vscode.window.showErrorMessage(l10n.t('No pull request URL found for this session.'));
1681
return;
1682
}
1683
1684
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
1685
resource,
1686
prompt: builtinSlashSCommands.updatePr,
1687
attachedContext: [{
1688
id: 'github-pull-request',
1689
fullName: pullRequestUrl,
1690
icon: new vscode.ThemeIcon('git-pull-request'),
1691
value: vscode.Uri.parse(pullRequestUrl),
1692
kind: 'generic'
1693
}]
1694
});
1695
}));
1696
1697
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => {
1698
logService.trace(`[commitToWorktree] Command invoked, args: ${JSON.stringify(args, null, 2)}`);
1699
if (!args?.worktreeUri || !args?.fileUri) {
1700
logService.debug('[commitToWorktree] Missing worktreeUri or fileUri, aborting');
1701
return;
1702
}
1703
1704
const worktreeUri = vscode.Uri.from(args.worktreeUri);
1705
const fileUri = vscode.Uri.from(args.fileUri);
1706
try {
1707
const fileName = basename(fileUri);
1708
await gitService.add(worktreeUri, [fileUri.fsPath]);
1709
logService.debug(`[commitToWorktree] Committing with message: Update customization: ${fileName}`);
1710
await gitService.commit(worktreeUri, l10n.t('Update customization: {0}', fileName), { noVerify: true, signCommit: false });
1711
logService.trace('[commitToWorktree] Commit successful');
1712
1713
// Clear the worktree changes cache so getWorktreeChanges() recomputes
1714
const sessionIds = await contentProvider.getAssociatedSessions(worktreeUri);
1715
await Promise.all(sessionIds.map(async sessionId => {
1716
const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1717
if (props) {
1718
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined });
1719
} else {
1720
logService.error('[commitToWorktree] No worktree properties found for session:', sessionId);
1721
}
1722
}));
1723
1724
logService.trace('[commitToWorktree] Notifying sessions change');
1725
if (sessionIds.length) {
1726
await contentProvider.refreshSession({ reason: 'update', sessionIds });
1727
}
1728
} catch (error) {
1729
const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string };
1730
const normalizedStdout = stdout.toLowerCase();
1731
const normalizedStderr = stderr.toLowerCase();
1732
if (normalizedStdout.includes('nothing to commit') || normalizedStderr.includes('nothing to commit') || gitErrorCode === 'NoLocalChanges' || gitErrorCode === 'NotAGitRepository') {
1733
logService.debug('[commitToWorktree] Nothing to commit or non-applicable repository state, skipping');
1734
return;
1735
}
1736
logService.error('[commitToWorktree] Error:', error);
1737
vscode.window.showErrorMessage(l10n.t('Failed to commit: {0}', error instanceof Error ? error.message : String(error)));
1738
}
1739
}));
1740
1741
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToRepository', async (args?: { repositoryUri?: vscode.Uri; fileUri?: vscode.Uri }) => {
1742
logService.trace(`[commitToRepository] Command invoked, args: ${JSON.stringify(args, null, 2)}`);
1743
if (!args?.repositoryUri || !args?.fileUri) {
1744
logService.debug('[commitToRepository] Missing repositoryUri or fileUri, aborting');
1745
return;
1746
}
1747
1748
const repositoryUri = vscode.Uri.from(args.repositoryUri);
1749
const fileUri = vscode.Uri.from(args.fileUri);
1750
try {
1751
const fileName = basename(fileUri);
1752
await gitService.add(repositoryUri, [fileUri.fsPath]);
1753
1754
const message = l10n.t('Update customization: {0}', fileName);
1755
logService.debug(`[commitToRepository] Committing with message: ${message}`);
1756
await gitService.commit(repositoryUri, message, { noVerify: true, signCommit: false });
1757
logService.trace('[commitToRepository] Commit successful');
1758
} catch (error) {
1759
const stderr = (error as { stderr?: string })?.stderr ?? '';
1760
const stdout = (error as { stdout?: string })?.stdout ?? '';
1761
const gitErrorCode = (error as { gitErrorCode?: string })?.gitErrorCode;
1762
1763
// Benign: nothing was staged or no local changes to commit
1764
if (stderr.includes('nothing to commit') || stdout.includes('nothing to commit') || gitErrorCode === 'NoLocalChanges') {
1765
logService.debug('[commitToRepository] Nothing to commit, skipping');
1766
return;
1767
}
1768
1769
// Benign: repository URI doesn't point to a git repo
1770
if (gitErrorCode === 'NotAGitRepository') {
1771
logService.debug('[commitToRepository] Not a git repository, skipping');
1772
return;
1773
}
1774
1775
logService.error('[commitToRepository] Error:', error);
1776
vscode.window.showErrorMessage(l10n.t("Could not save your customization to the default branch — this can happen when the worktree and the base repository have conflicting changes. Your change is still saved in this session's worktree."));
1777
}
1778
}));
1779
1780
return disposableStore;
1781
}
1782
1783
/**
1784
* Check if a path exists and is a directory.
1785
*/
1786
async function checkPathExists(filePath: vscode.Uri, fileSystemService: IFileSystemService): Promise<boolean> {
1787
try {
1788
const stat = await fileSystemService.stat(filePath);
1789
return stat.type === vscode.FileType.Directory;
1790
} catch {
1791
return false;
1792
}
1793
}
1794
1795
function isUnknownEventTypeError(error: unknown): boolean {
1796
const message = error instanceof Error ? error.message : String(error);
1797
return /Unknown event type:/i.test(message);
1798
}
1799
1800