Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.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 type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
7
import * as l10n from '@vscode/l10n';
8
import * as vscode from 'vscode';
9
import { ChatExtendedRequestHandler, ChatRequestTurn2, ChatSessionProviderOptionItem, Uri } from 'vscode';
10
import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService';
11
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
12
import { INativeEnvService } from '../../../platform/env/common/envService';
13
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
14
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
15
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
16
import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService';
17
import { toGitUri } from '../../../platform/git/common/utils';
18
import { derivePullRequestState } from '../../../platform/github/common/githubAPI';
19
import { IOctoKitService } from '../../../platform/github/common/githubService';
20
import { ILogService } from '../../../platform/log/common/logService';
21
import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';
22
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
23
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
24
import { isUri } from '../../../util/common/types';
25
import { DeferredPromise, disposableTimeout, IntervalTimer, raceCancellation, SequencerByKey } from '../../../util/vs/base/common/async';
26
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
27
import { isCancellationError } from '../../../util/vs/base/common/errors';
28
import { Emitter, Event } from '../../../util/vs/base/common/event';
29
import { Disposable, DisposableStore, IDisposable, IReference } from '../../../util/vs/base/common/lifecycle';
30
import { relative } from '../../../util/vs/base/common/path';
31
import { basename, dirname, extUri, isEqual } from '../../../util/vs/base/common/resources';
32
import { StopWatch } from '../../../util/vs/base/common/stopwatch';
33
import { URI } from '../../../util/vs/base/common/uri';
34
import { EXTENSION_ID } from '../../common/constants';
35
import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection';
36
import { GitBranchNameGenerator } from '../../prompt/node/gitBranch';
37
import { IToolsService } from '../../tools/common/toolsService';
38
import { IChatSessionMetadataStore, RepositoryProperties, StoredModeInstructions } from '../common/chatSessionMetadataStore';
39
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
40
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
41
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
42
import { FolderRepositoryInfo, FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
43
import { isUntitledSessionId } from '../common/utils';
44
import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
45
import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';
46
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
47
import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';
48
import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, formatModelDetails, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli';
49
import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver';
50
import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
51
import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
52
import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler';
53
import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';
54
import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions';
55
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
56
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
57
import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences';
58
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
59
60
const REPOSITORY_OPTION_ID = 'repository';
61
const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
62
63
const _sessionWorktreeIsolationCache = new Map<string, boolean>();
64
const BRANCH_OPTION_ID = 'branch';
65
const ISOLATION_OPTION_ID = 'isolation';
66
const PARENT_SESSION_OPTION_ID = 'parentSessionId';
67
const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption';
68
const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository';
69
const OPEN_IN_COPILOT_CLI_COMMAND_ID = 'github.copilot.cli.openInCopilotCLI';
70
const MAX_MRU_ENTRIES = 10;
71
const CHECK_FOR_STEERING_DELAY = 100; // ms
72
73
// When we start new sessions, we don't have the real session id, we have a temporary untitled id.
74
// We also need this when we open a session and later run it.
75
// When opening the session for readonly mode we store it here and when run the session we read from here instead of opening session in readonly mode again.
76
const _sessionBranch: Map<string, string | undefined> = new Map();
77
const _sessionIsolation: Map<string, IsolationMode | undefined> = new Map();
78
79
const _invalidCopilotCLISessionIdsWithErrorMessage = new Map<string, string>();
80
81
namespace SessionIdForCLI {
82
export function getResource(sessionId: string): vscode.Uri {
83
return vscode.Uri.from({
84
scheme: 'copilotcli', path: `/${sessionId}`,
85
});
86
}
87
88
export function parse(resource: vscode.Uri): string {
89
return resource.path.slice(1);
90
}
91
92
export function isCLIResource(resource: vscode.Uri): boolean {
93
return resource.scheme === 'copilotcli';
94
}
95
}
96
97
/**
98
* Escape XML special characters
99
*/
100
function escapeXml(text: string): string {
101
return text
102
.replace(/&/g, '&amp;')
103
.replace(/</g, '&lt;')
104
.replace(/>/g, '&gt;')
105
.replace(/"/g, '&quot;')
106
.replace(/'/g, '&apos;');
107
}
108
109
function getIssueRuntimeInfo(): { readonly platform: string; readonly vscodeInfo: string; readonly extensionVersion: string } {
110
const extensionVersion = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON?.version;
111
112
return {
113
platform: `${process.platform}-${process.arch}`,
114
vscodeInfo: `${vscode.env.appName} ${vscode.version}`,
115
extensionVersion: extensionVersion ?? 'unknown'
116
};
117
}
118
119
function getSessionLoadFailureIssueInfo(invalidSessionMessage: string): { readonly issueBody: string; readonly issueUrl: string } {
120
const runtimeInfo = getIssueRuntimeInfo();
121
const issueTitle = '[Copilot CLI] Failed to load chat session';
122
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\`\`\``;
123
const issueUrl = `https://github.com/microsoft/vscode/issues/new?title=${encodeURIComponent(issueTitle)}&body=${encodeURIComponent(issueBody)}`;
124
125
return { issueBody, issueUrl };
126
}
127
128
/**
129
* Resolves candidate session directories for a CLI terminal, ordered by
130
* terminal affinity.
131
*
132
* Sessions whose owning terminal matches `terminal` are returned first so the
133
* link provider's file-existence probing hits the correct session-state dir
134
* before unrelated ones. Unrelated sessions are still included at the tail
135
* because a new session may not have registered its terminal yet (session IDs
136
* arrive later via MCP?).
137
*/
138
export async function resolveSessionDirsForTerminal(
139
sessionTracker: ICopilotCLISessionTracker,
140
terminal: vscode.Terminal,
141
): Promise<Uri[]> {
142
const activeIds = sessionTracker.getSessionIds();
143
const matching: Uri[] = [];
144
const rest: Uri[] = [];
145
for (const id of activeIds) {
146
const sessionTerminal = await sessionTracker.getTerminal(id);
147
const dir = Uri.file(getCopilotCLISessionDir(id));
148
if (sessionTerminal === terminal) {
149
matching.push(dir);
150
} else {
151
rest.push(dir);
152
}
153
}
154
return [...matching, ...rest];
155
}
156
157
export class CopilotCLIChatSessionItemProvider extends Disposable implements vscode.ChatSessionItemProvider, ICopilotCLIChatSessionItemProvider {
158
// When we start an untitled CLI session, the id of the session is `untitled:xyz`
159
// As soon as we create a CLI session we have the real session id, lets say `cli-1234`
160
// Once the session completes, this untitled session `untitled:xyz` will get swapped with the real session id `cli-1234`
161
// However if the session items provider is called while the session is still running, we need to return the same old `untitled:xyz` session id back to core.
162
// There's an issue in core (about holding onto ref of the Chat Model).
163
// As a temporary solution, return the same untitled session id back to core until the session is completed.
164
public readonly untitledSessionIdMapping = new Map<string, string>();
165
/**
166
* Until the untitled session is properly swappped with the new session, we should keep track of this mapping.
167
* When VS Code asks for the session, always return the old untitled session Uri.
168
*/
169
public readonly sdkToUntitledUriMapping = new Map<string, Uri>();
170
private readonly _onDidChangeChatSessionItems = this._register(new Emitter<void>());
171
public readonly onDidChangeChatSessionItems: Event<void> = this._onDidChangeChatSessionItems.event;
172
173
private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
174
public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;
175
/**
176
* Session ids that were targeted by an explicit `refreshSession(...)` call and have not yet been
177
* re-provided. The next `provideChatSessionItems` pass eagerly includes `changes` for these
178
* sessions so the visible row reflects the latest diff info — VS Code uses the items returned
179
* from `provideChatSessionItems` as source of truth and does not re-invoke `resolveChatSessionItem`
180
* for already-visible rows. The set is cleared after each `provideChatSessionItems` call.
181
*/
182
private readonly pendingChangeIncludeIds = new Set<string>();
183
184
public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise<vscode.ChatSessionItem | undefined>;
185
186
constructor(
187
@ICopilotCLISessionService private readonly copilotcliSessionService: ICopilotCLISessionService,
188
@ICopilotCLISessionTracker private readonly sessionTracker: ICopilotCLISessionTracker,
189
@ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration,
190
@IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore,
191
@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,
192
@IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService,
193
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
194
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
195
@IGitService private readonly gitService: IGitService,
196
@IOctoKitService private readonly octoKitService: IOctoKitService,
197
@ILogService private readonly logService: ILogService,
198
@IConfigurationService private readonly configurationService: IConfigurationService,
199
) {
200
super();
201
this._register(this.terminalIntegration);
202
this._register(configurationService.onDidChangeConfiguration(e => {
203
if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) {
204
this._onDidChangeChatSessionItems.fire();
205
}
206
}));
207
208
if (configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
209
this.resolveChatSessionItem = async (item: vscode.ChatSessionItem, token: vscode.CancellationToken): Promise<vscode.ChatSessionItem | undefined> => {
210
const sessionId = SessionIdForCLI.parse(item.resource);
211
const session = await this.copilotcliSessionService.getSessionItem(sessionId, token);
212
if (!session || token.isCancellationRequested) {
213
return undefined;
214
}
215
return this.toChatSessionItem(session, { includeChanges: true }, token);
216
};
217
}
218
219
// Resolve session dirs for terminal links. See resolveSessionDirsForTerminal.
220
this.terminalIntegration.setSessionDirResolver(terminal =>
221
resolveSessionDirsForTerminal(this.sessionTracker, terminal)
222
);
223
224
this._register(this.copilotcliSessionService.onDidChangeSessions(() => {
225
this.notifySessionsChange();
226
}));
227
}
228
229
public getAssociatedSessions(folder: Uri): string[] {
230
return this.chatSessionMetadataStore.getSessionIdsForFolder(folder);
231
}
232
233
/**
234
* We should remove this or move this to CopilotCLISessionService
235
*/
236
public isNewSession(session: string) {
237
return isUntitledSessionId(session);
238
}
239
240
public notifySessionsChange(): void {
241
// Refresh the bulk metadata cache from disk so cross-process writes
242
// (e.g. another VS Code window editing the same session) become visible
243
// before consumers re-read items.
244
this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });
245
this._onDidChangeChatSessionItems.fire();
246
}
247
248
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void> {
249
await this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });
250
if (refreshOptions.reason === 'update') {
251
// Mark the targeted sessions so the next `provideChatSessionItems` pass includes
252
// fresh `changes` for them (push path equivalent — see `pendingChangeIncludeIds`).
253
if ('sessionIds' in refreshOptions) {
254
for (const id of refreshOptions.sessionIds) {
255
this.pendingChangeIncludeIds.add(id);
256
}
257
} else {
258
this.pendingChangeIncludeIds.add(refreshOptions.sessionId);
259
}
260
}
261
this._onDidChangeChatSessionItems.fire();
262
}
263
264
public swap(original: vscode.ChatSessionItem, modified: vscode.ChatSessionItem): void {
265
this._onDidCommitChatSessionItem.fire({ original, modified });
266
}
267
268
public async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {
269
const stopwatch = new StopWatch();
270
const sessions = await this.copilotcliSessionService.getAllSessions(token);
271
// Drain the pending set: sessions that were explicitly refreshed get `changes` populated
272
// eagerly so the visible row reflects the latest diff info on this re-provide pass.
273
const pendingIds = new Set(this.pendingChangeIncludeIds);
274
this.pendingChangeIncludeIds.clear();
275
const diskSessions = await Promise.all(sessions.map(async session => this.toChatSessionItem(
276
session,
277
pendingIds.has(session.id) ? { includeChanges: true } : undefined,
278
token,
279
)));
280
281
const count = diskSessions.length;
282
void this.commandExecutionService.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0);
283
this.logService.info(`[CopilotCLIChatSessionContentProvider] listSessions took ${stopwatch.elapsed()}ms`);
284
return diskSessions;
285
}
286
287
private shouldShowBadge(): boolean {
288
const repositories = this.gitService.repositories
289
.filter(repository => repository.kind !== 'worktree');
290
291
return vscode.workspace.workspaceFolders === undefined || // empty window
292
vscode.workspace.isAgentSessionsWorkspace || // agent sessions workspace
293
repositories.length > 1; // multiple repositories
294
}
295
296
public async toChatSessionItem(session: ICopilotCLISessionItem, options?: { readonly includeChanges?: boolean }, token: vscode.CancellationToken = CancellationToken.None): Promise<vscode.ChatSessionItem> {
297
const resource = this.sdkToUntitledUriMapping.get(session.id) ?? SessionIdForCLI.getResource(this.untitledSessionIdMapping.get(session.id) ?? session.id);
298
let worktreeProperties = await raceCancellation(this.worktreeManager.getWorktreeProperties(session.id), token);
299
const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath)
300
: session.workingDirectory;
301
302
const label = session.label;
303
304
// Badge
305
let badge: vscode.MarkdownString | undefined;
306
if (this.shouldShowBadge() && !token.isCancellationRequested) {
307
if (worktreeProperties?.repositoryPath) {
308
// Worktree
309
const repositoryPathUri = vscode.Uri.file(worktreeProperties.repositoryPath);
310
const isTrusted = await vscode.workspace.isResourceTrusted(repositoryPathUri);
311
const badgeIcon = isTrusted ? '$(repo)' : '$(workspace-untrusted)';
312
313
badge = new vscode.MarkdownString(`${badgeIcon} ${basename(repositoryPathUri)}`);
314
badge.supportThemeIcons = true;
315
} else if (workingDirectory) {
316
// Workspace
317
const isTrusted = await vscode.workspace.isResourceTrusted(workingDirectory);
318
const badgeIcon = isTrusted ? '$(folder)' : '$(workspace-untrusted)';
319
320
badge = new vscode.MarkdownString(`${badgeIcon} ${basename(workingDirectory)}`);
321
badge.supportThemeIcons = true;
322
}
323
}
324
325
// Statistics (only returned for trusted workspace/worktree folders).
326
// `getWorktreeChanges`/`getWorkspaceChanges` shell out to `git diff` and dominate the cost
327
// of building an item — defer to `resolveChatSessionItem` for visible items.
328
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
329
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
330
// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
331
let changes: vscode.ChatSessionChangedFile[] | undefined;
332
if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) {
333
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
334
// We need to get an updated version of worktree properties here because when the
335
// changes are being computed, the worktree properties are also updated with the
336
// repository state which we are passing along through the metadata
337
worktreeProperties = await raceCancellation(this.worktreeManager.getWorktreeProperties(session.id), token);
338
}
339
340
// Status
341
const status = session.status ?? vscode.ChatSessionStatus.Completed;
342
343
// Metadata
344
let metadata: { readonly [key: string]: unknown };
345
const sessionParentId = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token);
346
347
if (worktreeProperties) {
348
// Worktree
349
metadata = {
350
sessionParentId,
351
autoCommit: worktreeProperties.autoCommit !== false,
352
baseCommit: worktreeProperties?.baseCommit,
353
baseBranchName: worktreeProperties.version === 2
354
? worktreeProperties.baseBranchName
355
: undefined,
356
baseBranchProtected: worktreeProperties.version === 2
357
? worktreeProperties.baseBranchProtected === true
358
: undefined,
359
branchName: worktreeProperties?.branchName,
360
upstreamBranchName: worktreeProperties.version === 2
361
? worktreeProperties.upstreamBranchName
362
: undefined,
363
isolationMode: IsolationMode.Worktree,
364
repositoryPath: worktreeProperties?.repositoryPath,
365
worktreePath: worktreeProperties?.worktreePath,
366
pullRequestUrl: worktreeProperties.version === 2
367
? worktreeProperties.pullRequestUrl
368
: undefined,
369
pullRequestState: worktreeProperties.version === 2
370
? worktreeProperties.pullRequestState
371
: undefined,
372
firstCheckpointRef: worktreeProperties.version === 2
373
? worktreeProperties.firstCheckpointRef
374
: undefined,
375
baseCheckpointRef: worktreeProperties.version === 2
376
? worktreeProperties.baseCheckpointRef
377
: undefined,
378
lastCheckpointRef: worktreeProperties.version === 2
379
? worktreeProperties.lastCheckpointRef
380
: undefined,
381
hasGitHubRemote: worktreeProperties.version === 2
382
? worktreeProperties.hasGitHubRemote
383
: undefined,
384
incomingChanges: worktreeProperties.version === 2
385
? worktreeProperties.incomingChanges
386
: undefined,
387
outgoingChanges: worktreeProperties.version === 2
388
? worktreeProperties.outgoingChanges
389
: undefined,
390
uncommittedChanges: worktreeProperties.version === 2
391
? worktreeProperties.uncommittedChanges
392
: undefined
393
} satisfies { readonly [key: string]: unknown };
394
} else {
395
// Workspace
396
const sessionRequestDetails = await raceCancellation(this.chatSessionMetadataStore.getRequestDetails(session.id), token) ?? [];
397
const repositoryProperties = await raceCancellation(this.chatSessionMetadataStore.getRepositoryProperties(session.id), token);
398
399
let lastCheckpointRef: string | undefined;
400
for (let i = sessionRequestDetails.length - 1; i >= 0; i--) {
401
const checkpointRef = sessionRequestDetails[i]?.checkpointRef;
402
if (checkpointRef !== undefined) {
403
lastCheckpointRef = checkpointRef;
404
break;
405
}
406
}
407
408
const firstCheckpointRef = lastCheckpointRef
409
? `${lastCheckpointRef.slice(0, lastCheckpointRef.lastIndexOf('/'))}/0`
410
: undefined;
411
412
metadata = {
413
sessionParentId,
414
isolationMode: IsolationMode.Workspace,
415
repositoryPath: repositoryProperties?.repositoryPath,
416
branchName: repositoryProperties?.branchName,
417
baseBranchName: repositoryProperties?.baseBranchName,
418
upstreamBranchName: repositoryProperties?.upstreamBranchName,
419
workingDirectoryPath: workingDirectory?.fsPath,
420
hasGitHubRemote: repositoryProperties?.hasGitHubRemote,
421
incomingChanges: repositoryProperties?.incomingChanges,
422
outgoingChanges: repositoryProperties?.outgoingChanges,
423
uncommittedChanges: repositoryProperties?.uncommittedChanges,
424
firstCheckpointRef,
425
lastCheckpointRef
426
} satisfies { readonly [key: string]: unknown };
427
}
428
429
return {
430
resource,
431
label,
432
badge,
433
timing: session.timing,
434
changes,
435
status,
436
metadata,
437
} satisfies vscode.ChatSessionItem;
438
}
439
440
private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
441
if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
442
return true;
443
}
444
const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
445
this.worktreeManager.hasCachedChanges(sessionId),
446
this.workspaceFolderService.hasCachedChanges(sessionId)
447
]);
448
return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;
449
}
450
451
452
private async buildChanges(
453
sessionId: string,
454
worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,
455
workingDirectory: vscode.Uri | undefined,
456
token: vscode.CancellationToken
457
): Promise<vscode.ChatSessionChangedFile[]> {
458
const changes: vscode.ChatSessionChangedFile[] = [];
459
if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) {
460
changes.push(...(await raceCancellation(this.worktreeManager.getWorktreeChanges(sessionId), token) ?? []));
461
} else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) {
462
const workspaceChanges = await raceCancellation(this.workspaceFolderService.getWorkspaceChanges(sessionId), token) ?? [];
463
const repositoryProperties = await raceCancellation(this.chatSessionMetadataStore.getRepositoryProperties(sessionId), token);
464
465
changes.push(...workspaceChanges.map(change => {
466
const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD';
467
468
return new vscode.ChatSessionChangedFile(
469
vscode.Uri.file(change.filePath),
470
change.originalFilePath
471
? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef)
472
: undefined,
473
change.modifiedFilePath
474
? vscode.Uri.file(change.modifiedFilePath)
475
: undefined,
476
change.statistics.additions,
477
change.statistics.deletions);
478
}));
479
}
480
return changes;
481
}
482
483
/**
484
* Detects a pull request for a session when the user opens it.
485
* If a PR is found, persists the URL and notifies the UI.
486
*/
487
public async detectPullRequestOnSessionOpen(sessionId: string): Promise<void> {
488
try {
489
const worktreeProperties = await this.worktreeManager.getWorktreeProperties(sessionId);
490
if (worktreeProperties?.version !== 2
491
|| worktreeProperties.pullRequestState === 'merged'
492
|| !worktreeProperties.branchName
493
|| !worktreeProperties.repositoryPath) {
494
this.logService.debug(`[CopilotCLIChatSessionItemProvider] Skipping PR detection on session open for ${sessionId}: version=${worktreeProperties?.version}, prState=${worktreeProperties?.version === 2 ? worktreeProperties.pullRequestState : 'n/a'}, branch=${!!worktreeProperties?.branchName}, repoPath=${!!worktreeProperties?.repositoryPath}`);
495
return;
496
}
497
498
this.logService.debug(`[CopilotCLIChatSessionItemProvider] Detecting PR on session open for ${sessionId}, branch=${worktreeProperties.branchName}, existingPrUrl=${worktreeProperties.pullRequestUrl ?? 'none'}`);
499
500
const prResult = await detectPullRequestFromGitHubAPI(
501
worktreeProperties.branchName,
502
worktreeProperties.repositoryPath,
503
this.gitService,
504
this.octoKitService,
505
this.logService,
506
);
507
508
if (prResult) {
509
const currentProperties = await this.worktreeManager.getWorktreeProperties(sessionId);
510
if (currentProperties?.version === 2
511
&& (currentProperties.pullRequestUrl !== prResult.url || currentProperties.pullRequestState !== prResult.state)) {
512
this.logService.debug(`[CopilotCLIChatSessionItemProvider] Updating PR metadata for ${sessionId}: url=${prResult.url}, state=${prResult.state} (was url=${currentProperties.pullRequestUrl ?? 'none'}, state=${currentProperties.pullRequestState ?? 'none'})`);
513
await this.worktreeManager.setWorktreeProperties(sessionId, {
514
...currentProperties,
515
pullRequestUrl: prResult.url,
516
pullRequestState: prResult.state,
517
changes: undefined,
518
});
519
this.notifySessionsChange();
520
} else {
521
this.logService.debug(`[CopilotCLIChatSessionItemProvider] PR metadata unchanged for ${sessionId}, skipping update`);
522
}
523
} else {
524
this.logService.debug(`[CopilotCLIChatSessionItemProvider] No PR found via GitHub API for ${sessionId}`);
525
}
526
} catch (error) {
527
this.logService.trace(`[CopilotCLIChatSessionItemProvider] Failed to detect pull request on session open for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
528
}
529
}
530
public async createCopilotCLITerminal(location: TerminalOpenLocation = 'editor', name?: string, cwd?: string): Promise<void> {
531
// TODO@rebornix should be set by CLI
532
const terminalName = name || process.env.COPILOTCLI_TERMINAL_TITLE || l10n.t('Copilot CLI');
533
await this.terminalIntegration.openTerminal(terminalName, [], cwd, location);
534
}
535
536
public async resumeCopilotCLISessionInTerminal(sessionItem: vscode.ChatSessionItem): Promise<void> {
537
const id = SessionIdForCLI.parse(sessionItem.resource);
538
const existingTerminal = await this.sessionTracker.getTerminal(id);
539
if (existingTerminal) {
540
existingTerminal.show();
541
return;
542
}
543
544
const terminalName = sessionItem.label || id;
545
const cliArgs = ['--resume', id];
546
const token = new vscode.CancellationTokenSource();
547
try {
548
const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token.token);
549
const cwd = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;
550
const terminal = await this.terminalIntegration.openTerminal(terminalName, cliArgs, cwd?.fsPath);
551
if (terminal) {
552
this.sessionTracker.setSessionTerminal(id, terminal);
553
this.terminalIntegration.setTerminalSessionDir(terminal, Uri.file(getCopilotCLISessionDir(id)));
554
}
555
} finally {
556
token.dispose();
557
}
558
}
559
}
560
561
function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean {
562
return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport);
563
}
564
565
function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean {
566
return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption);
567
}
568
569
function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean {
570
return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled);
571
}
572
573
export class CopilotCLIChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider {
574
private readonly _onDidChangeChatSessionOptions = this._register(new Emitter<vscode.ChatSessionOptionChangeEvent>());
575
readonly onDidChangeChatSessionOptions = this._onDidChangeChatSessionOptions.event;
576
private readonly _onDidChangeChatSessionProviderOptions = this._register(new Emitter<void>());
577
readonly onDidChangeChatSessionProviderOptions = this._onDidChangeChatSessionProviderOptions.event;
578
579
private _currentSessionId: string | undefined;
580
private _selectedRepoForBranches: { repoUri: URI; headBranchName: string | undefined } | undefined;
581
private _displayedOptionIds = new Set<string>();
582
private readonly _activeSessionsById = new Map<string, ICopilotCLISession>();
583
/**
584
* ID of the last used folder in an untitled workspace (for defaulting selection).
585
*/
586
private _lastUsedFolderIdInUntitledWorkspace: string | undefined;
587
constructor(
588
private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider,
589
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
590
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
591
@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
592
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
593
@IFileSystemService private readonly fileSystem: IFileSystemService,
594
@IGitService private readonly gitService: IGitService,
595
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
596
@IConfigurationService private readonly configurationService: IConfigurationService,
597
@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,
598
@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,
599
@ILogService private readonly logService: ILogService,
600
@IChatFolderMruService private readonly folderMruService: IChatFolderMruService,
601
) {
602
super();
603
const originalRepos = this.getRepositoryOptionItems().length;
604
this._register(this.gitService.onDidFinishInitialization(() => {
605
if (originalRepos !== this.getRepositoryOptionItems().length) {
606
this._onDidChangeChatSessionProviderOptions.fire();
607
}
608
}));
609
this._register(this.gitService.onDidOpenRepository(() => {
610
if (originalRepos !== this.getRepositoryOptionItems().length) {
611
this._onDidChangeChatSessionProviderOptions.fire();
612
}
613
}));
614
this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => {
615
this._onDidChangeChatSessionProviderOptions.fire();
616
}));
617
this._register(this.copilotCLIAgents.onDidChangeAgents(() => {
618
this._onDidChangeChatSessionProviderOptions.fire();
619
}));
620
}
621
622
public notifySessionOptionsChange(resource: vscode.Uri, updates: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }>): void {
623
this._onDidChangeChatSessionOptions.fire({ resource, updates });
624
}
625
626
public notifyProviderOptionsChange(): void {
627
this._onDidChangeChatSessionProviderOptions.fire();
628
}
629
630
private async getDefaultUntitledSessionRepositoryOption(copilotcliSessionId: string | undefined, token: vscode.CancellationToken) {
631
const repositories = this.isUntitledWorkspace() ? folderMRUToChatProviderOptions(await this.folderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems();
632
// Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population)
633
const folderInfo = copilotcliSessionId ? await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token) : undefined;
634
const uri = folderInfo?.repository ?? folderInfo?.folder;
635
if (uri) {
636
return uri;
637
} else if (repositories.length) {
638
// No folder selected yet for this untitled session - use MRU or first available
639
const lastUsedFolderId = this._lastUsedFolderIdInUntitledWorkspace;
640
const firstRepo = (lastUsedFolderId && repositories.find(repo => repo.id === lastUsedFolderId)?.id) ?? repositories[0].id;
641
return Uri.file(firstRepo);
642
}
643
return undefined;
644
}
645
646
async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {
647
const stopwatch = new StopWatch();
648
try {
649
const copilotcliSessionId = SessionIdForCLI.parse(resource);
650
const isUntitled = this.sessionItemProvider.isNewSession(copilotcliSessionId);
651
if (isUntitled) {
652
return await this.provideChatSessionContentForUntitledSession(resource, token);
653
} else {
654
return await this.provideChatSessionContentForExistingSession(resource, token);
655
}
656
} finally {
657
this.logService.info(`[CopilotCLIChatSessionContentProvider] provideChatSessionContent for ${resource.toString()} took ${stopwatch.elapsed()}ms`);
658
}
659
}
660
661
public trackLastUsedFolderInWelcomeView(folderUri: vscode.Uri): void {
662
// Update MRU tracking for untitled workspaces
663
if (isWelcomeView(this.workspaceService)) {
664
this._lastUsedFolderIdInUntitledWorkspace = folderUri.fsPath;
665
}
666
}
667
668
async provideChatSessionContentForUntitledSession(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {
669
const copilotcliSessionId = SessionIdForCLI.parse(resource);
670
this._currentSessionId = copilotcliSessionId;
671
const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);
672
const isUntitled = this.sessionItemProvider.isNewSession(copilotcliSessionId);
673
const [history, title] = await Promise.all([
674
isUntitled ? Promise.resolve([]) : this.getSessionHistory(copilotcliSessionId, folderRepo, token),
675
this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId),
676
]);
677
678
const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};
679
680
// Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population)
681
const defaultRepo = await this.getDefaultUntitledSessionRepositoryOption(copilotcliSessionId, token);
682
if (defaultRepo) {
683
// Determine upfront whether the default repository/folder is trusted. We need to do
684
// this since the user should not be presented with a resource trust dialog in case the
685
// default repository/folder is not trusted.
686
const defaultRepoIsTrusted = await vscode.workspace.isResourceTrusted(defaultRepo);
687
688
if (defaultRepoIsTrusted) {
689
options[REPOSITORY_OPTION_ID] = defaultRepo.fsPath;
690
// Use the manager to track the selection for untitled sessions
691
this.trackLastUsedFolderInWelcomeView(defaultRepo);
692
this.folderRepositoryManager.setNewSessionFolder(copilotcliSessionId, defaultRepo);
693
694
// Check if the default folder is a git repo so the branch dropdown appears immediately
695
const repoInfo = await this.folderRepositoryManager.getRepositoryInfo(defaultRepo, token);
696
if (repoInfo.repository) {
697
this._selectedRepoForBranches = { repoUri: repoInfo.repository, headBranchName: repoInfo.headBranchName };
698
} else {
699
this._selectedRepoForBranches = undefined;
700
}
701
if (repoInfo.repository && isIsolationOptionFeatureEnabled(this.configurationService)) {
702
if (!_sessionIsolation.has(copilotcliSessionId)) {
703
const lastUsed = this.context.globalState.get<IsolationMode>(LAST_USED_ISOLATION_OPTION_KEY, IsolationMode.Workspace);
704
_sessionIsolation.set(copilotcliSessionId, lastUsed);
705
}
706
const isolationMode = _sessionIsolation.get(copilotcliSessionId)!;
707
options[ISOLATION_OPTION_ID] = {
708
id: isolationMode,
709
name: isolationMode === IsolationMode.Worktree ? l10n.t('Worktree') : l10n.t('Workspace'),
710
icon: new vscode.ThemeIcon(isolationMode === IsolationMode.Worktree ? 'worktree' : 'folder')
711
};
712
}
713
const shouldShowBranch = !isIsolationOptionFeatureEnabled(this.configurationService) || _sessionIsolation.get(copilotcliSessionId) === IsolationMode.Worktree;
714
const branchItems = await this.getBranchOptionItems();
715
if (branchItems.length > 0 && shouldShowBranch) {
716
_sessionBranch.set(copilotcliSessionId, branchItems[0].id);
717
options[BRANCH_OPTION_ID] = {
718
id: branchItems[0].id,
719
name: branchItems[0].name,
720
icon: new vscode.ThemeIcon('git-branch')
721
};
722
}
723
} else {
724
options[REPOSITORY_OPTION_ID] = '';
725
}
726
727
this.notifyProviderOptionsChange();
728
}
729
730
return {
731
title,
732
history,
733
activeResponseCallback: undefined,
734
requestHandler: undefined,
735
options: options
736
};
737
}
738
739
async provideChatSessionContentForExistingSession(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {
740
const copilotcliSessionId = SessionIdForCLI.parse(resource);
741
this._currentSessionId = copilotcliSessionId;
742
743
// Fire-and-forget: detect PR when the user opens a session
744
void this.sessionItemProvider.detectPullRequestOnSessionOpen(copilotcliSessionId);
745
746
const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);
747
const [history, title, folderInfo, worktreeProperties] = await Promise.all([
748
this.getSessionHistory(copilotcliSessionId, folderRepo, token),
749
this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId),
750
this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token),
751
this.copilotCLIWorktreeManagerService.getWorktreeProperties(copilotcliSessionId)
752
]);
753
754
const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};
755
if (folderInfo.repository) {
756
options[REPOSITORY_OPTION_ID] = {
757
...toRepositoryOptionItem(folderInfo.repository),
758
locked: true
759
};
760
} else if (folderInfo.folder) {
761
const folderName = this.workspaceService.getWorkspaceFolderName(folderInfo.folder) || basename(folderInfo.folder);
762
options[REPOSITORY_OPTION_ID] = {
763
...toWorkspaceFolderOptionItem(folderInfo.folder, folderName),
764
locked: true
765
};
766
} else {
767
// Existing session with no folder info - show unknown
768
let folderName = l10n.t('Unknown');
769
if (this.workspaceService.getWorkspaceFolders().length === 1) {
770
folderName = this.workspaceService.getWorkspaceFolderName(this.workspaceService.getWorkspaceFolders()[0]) || folderName;
771
}
772
options[REPOSITORY_OPTION_ID] = {
773
id: '',
774
name: folderName,
775
icon: new vscode.ThemeIcon('folder'),
776
locked: true
777
};
778
}
779
if (worktreeProperties?.repositoryPath) {
780
const branchName = worktreeProperties.branchName;
781
const repoUri = vscode.Uri.file(worktreeProperties.repositoryPath);
782
this._selectedRepoForBranches = { repoUri, headBranchName: branchName };
783
784
options[BRANCH_OPTION_ID] = {
785
id: branchName,
786
name: branchName,
787
icon: new vscode.ThemeIcon('git-branch'),
788
locked: true
789
};
790
}
791
if (isIsolationOptionFeatureEnabled(this.configurationService)) {
792
const isWorktree = !!worktreeProperties;
793
options[ISOLATION_OPTION_ID] = {
794
id: isWorktree ? IsolationMode.Worktree : IsolationMode.Workspace,
795
name: isWorktree ? l10n.t('Worktree') : l10n.t('Workspace'),
796
icon: new vscode.ThemeIcon(isWorktree ? 'worktree' : 'folder'),
797
locked: true
798
};
799
}
800
801
// Ensure the branch option group is shown when we have a branch value but it's not displayed.
802
if (options[BRANCH_OPTION_ID] && !this._displayedOptionIds.has(BRANCH_OPTION_ID)) {
803
this.notifyProviderOptionsChange();
804
}
805
806
if (this.configurationService.getConfig(ConfigKey.Advanced.CLIForkSessionsEnabled)) {
807
return {
808
title,
809
history,
810
activeResponseCallback: undefined,
811
requestHandler: undefined,
812
options: options,
813
forkHandler: async (sessionResource, requestTurn, token) => {
814
const sessionId = SessionIdForCLI.parse(sessionResource);
815
return this.forkSession(sessionId, requestTurn?.id, token);
816
},
817
};
818
} else {
819
return {
820
title,
821
history,
822
activeResponseCallback: undefined,
823
requestHandler: undefined,
824
options: options,
825
};
826
}
827
}
828
829
private async forkSession(sessionId: string, requestId: string | undefined, token: CancellationToken): Promise<vscode.ChatSessionItem> {
830
const folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, undefined, token);
831
const forkedSessionId = await this.sessionService.forkSession({ sessionId, requestId, workspace: folderInfo }, token);
832
833
const items = await this.sessionItemProvider.provideChatSessionItems(token);
834
const forkedSessionUri = SessionIdForCLI.getResource(forkedSessionId);
835
const item = items.find(i => isEqual(i.resource, forkedSessionUri));
836
if (!item) {
837
throw new Error(`Failed to find session item for forked session ${forkedSessionId}`);
838
}
839
return item;
840
}
841
842
private async getSessionHistory(sessionId: string, workspaceInfo: IWorkspaceInfo, token: vscode.CancellationToken) {
843
try {
844
_invalidCopilotCLISessionIdsWithErrorMessage.delete(sessionId);
845
const history = await this.sessionService.getChatHistory({ sessionId, workspace: workspaceInfo }, token);
846
return history;
847
} catch (error) {
848
if (!isUnknownEventTypeError(error)) {
849
throw error;
850
}
851
852
const partialHistory = await this.sessionService.tryGetPartialSessionHistory(sessionId);
853
if (partialHistory) {
854
_invalidCopilotCLISessionIdsWithErrorMessage.set(sessionId, error.message || String(error));
855
return partialHistory;
856
}
857
858
throw error;
859
}
860
}
861
862
async provideChatSessionProviderOptions(): Promise<vscode.ChatSessionProviderOptions> {
863
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
864
865
if (this._selectedRepoForBranches && isIsolationOptionFeatureEnabled(this.configurationService)) {
866
optionGroups.push({
867
id: ISOLATION_OPTION_ID,
868
name: l10n.t('Isolation'),
869
description: l10n.t('Pick Isolation Mode'),
870
items: [
871
{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },
872
{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },
873
]
874
});
875
}
876
877
// Handle repository options based on workspace type
878
if (this.isUntitledWorkspace()) {
879
// For untitled workspaces, show last used repositories and "Open Repository..." command
880
const repositories = await this.folderMruService.getRecentlyUsedFolders(CancellationToken.None);
881
const items = folderMRUToChatProviderOptions(repositories);
882
items.splice(MAX_MRU_ENTRIES); // Limit to max entries
883
884
if (this._lastUsedFolderIdInUntitledWorkspace && !items.some(repo => repo.id === this._lastUsedFolderIdInUntitledWorkspace)) {
885
const uri = Uri.file(this._lastUsedFolderIdInUntitledWorkspace);
886
items.unshift(toWorkspaceFolderOptionItem(uri, basename(uri)));
887
}
888
889
const commands: vscode.Command[] = [];
890
commands.push({
891
command: OPEN_REPOSITORY_COMMAND_ID,
892
title: l10n.t('Browse folders...')
893
});
894
895
optionGroups.push({
896
id: REPOSITORY_OPTION_ID,
897
name: l10n.t('Folder'),
898
description: l10n.t('Pick Folder'),
899
items,
900
commands
901
});
902
} else {
903
const repositories = this.getRepositoryOptionItems();
904
if (repositories.length > 1) {
905
optionGroups.push({
906
id: REPOSITORY_OPTION_ID,
907
name: l10n.t('Folder'),
908
description: l10n.t('Pick Folder'),
909
items: repositories
910
});
911
}
912
}
913
914
if (this._selectedRepoForBranches && (isBranchOptionFeatureEnabled(this.configurationService) || (await this.isWorktreeIsolationSelected()))) {
915
const branchItems = await this.getBranchOptionItems(true);
916
if (branchItems.length > 0) {
917
optionGroups.push({
918
id: BRANCH_OPTION_ID,
919
name: l10n.t('Branch'),
920
description: l10n.t('Pick Branch'),
921
items: branchItems,
922
// icon: new vscode.ThemeIcon('git-branch')
923
});
924
}
925
}
926
927
this._displayedOptionIds.clear();
928
optionGroups.forEach(group => {
929
this._displayedOptionIds.add(group.id);
930
});
931
return { optionGroups };
932
}
933
934
private _branchRepositoryOptions?: { repoUri: Uri; items: Promise<vscode.ChatSessionProviderOptionItem[]> };
935
private async getBranchOptionItems(overrideListBranches = false): Promise<vscode.ChatSessionProviderOptionItem[]> {
936
if (!this._selectedRepoForBranches) {
937
return [];
938
}
939
940
if (!overrideListBranches && !isBranchOptionFeatureEnabled(this.configurationService)) {
941
return [];
942
}
943
944
const { repoUri, headBranchName } = this._selectedRepoForBranches;
945
if (!this._branchRepositoryOptions || !isEqual(repoUri, this._branchRepositoryOptions.repoUri)) {
946
this._branchRepositoryOptions = {
947
repoUri,
948
items: this.getBranchOptionItemsForRepository(repoUri, headBranchName)
949
};
950
}
951
return this._branchRepositoryOptions.items;
952
}
953
954
private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey<string>();
955
private async getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]> {
956
const key = `${repoUri.toString()}${headBranchName}`;
957
return this._getBranchOptionItemsForRepositorySequencer.queue(key, async () => {
958
959
const refs = await this.gitService.getRefs(repoUri, { sort: 'committerdate' });
960
961
// Filter to local branches only (RefType.Head === 0)
962
const localBranches = refs.filter(ref => ref.type === 0 /* RefType.Head */ && ref.name);
963
964
// Build items with HEAD branch first
965
const items: vscode.ChatSessionProviderOptionItem[] = [];
966
let headItem: vscode.ChatSessionProviderOptionItem | undefined;
967
968
for (const ref of localBranches) {
969
const isHead = ref.name === headBranchName;
970
const item: vscode.ChatSessionProviderOptionItem = {
971
id: ref.name!,
972
name: ref.name!,
973
icon: new vscode.ThemeIcon('git-branch'),
974
// default: isHead
975
};
976
if (isHead) {
977
headItem = item;
978
} else {
979
items.push(item);
980
}
981
}
982
983
if (headItem) {
984
items.unshift(headItem);
985
}
986
987
return items;
988
});
989
}
990
991
/**
992
* Check if the current workspace is untitled (has no workspace folders).
993
*/
994
private isUntitledWorkspace(): boolean {
995
return this.workspaceService.getWorkspaceFolders().length === 0;
996
}
997
998
/**
999
* Check if the current session has worktree isolation selected.
1000
* Used to determine whether the branch picker should be shown.
1001
*/
1002
private async isWorktreeIsolationSelected(): Promise<boolean> {
1003
if (!isIsolationOptionFeatureEnabled(this.configurationService)) {
1004
return true;
1005
}
1006
1007
if (!this._currentSessionId) {
1008
return false;
1009
}
1010
1011
const sessionId = this._currentSessionId;
1012
const cached = _sessionWorktreeIsolationCache.get(sessionId);
1013
if (typeof cached === 'boolean') {
1014
return cached;
1015
}
1016
1017
if (isUntitledSessionId(sessionId)) {
1018
const isWorktree = _sessionIsolation.get(sessionId) === IsolationMode.Worktree;
1019
_sessionWorktreeIsolationCache.set(sessionId, isWorktree);
1020
return isWorktree;
1021
}
1022
1023
if (_sessionIsolation.get(sessionId) === IsolationMode.Worktree) {
1024
_sessionWorktreeIsolationCache.set(sessionId, true);
1025
return true;
1026
}
1027
1028
const folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, undefined, CancellationToken.None);
1029
const isWorktree = !!folderInfo.worktreeProperties;
1030
_sessionWorktreeIsolationCache.set(sessionId, isWorktree);
1031
return isWorktree;
1032
}
1033
1034
private getRepositoryOptionItems() {
1035
// Exclude worktrees from the repository list
1036
const repositories = this.gitService.repositories
1037
.filter(repository => repository.kind !== 'worktree')
1038
.filter(repository => {
1039
if (this.isUntitledWorkspace()) {
1040
return true;
1041
}
1042
// Only include repositories that belong to one of the workspace folders
1043
return this.workspaceService.getWorkspaceFolder(repository.rootUri) !== undefined;
1044
});
1045
1046
const repoItems = repositories
1047
.map(repository => toRepositoryOptionItem(repository));
1048
1049
// In multi-root workspaces, also include workspace folders that don't have any git repos
1050
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
1051
if (workspaceFolders.length) {
1052
// Find workspace folders that contain git repos
1053
const foldersWithRepos = new Set<string>();
1054
for (const repo of repositories) {
1055
const folder = this.workspaceService.getWorkspaceFolder(repo.rootUri);
1056
if (folder) {
1057
foldersWithRepos.add(folder.fsPath);
1058
}
1059
}
1060
1061
// Add workspace folders that don't have any git repos
1062
for (const folder of workspaceFolders) {
1063
if (!foldersWithRepos.has(folder.fsPath)) {
1064
const folderName = this.workspaceService.getWorkspaceFolderName(folder);
1065
repoItems.push(toWorkspaceFolderOptionItem(folder, folderName));
1066
}
1067
}
1068
}
1069
1070
return repoItems.sort((a, b) => a.name.localeCompare(b.name));
1071
}
1072
1073
1074
// Handle option changes for a session (store current state in a map)
1075
async provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, token: vscode.CancellationToken): Promise<void> {
1076
const sessionId = SessionIdForCLI.parse(resource);
1077
this._currentSessionId = sessionId;
1078
const wasBranchOptionShow = !!this._selectedRepoForBranches;
1079
let triggerProviderOptionsChange = false;
1080
for (const update of updates) {
1081
if (update.optionId === PERMISSION_LEVEL_OPTION_ID) {
1082
const level = typeof update.value === 'string' ? update.value : undefined;
1083
this._getActiveSessionForResourceId(sessionId)?.setPermissionLevel(level);
1084
} else if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && this.sessionItemProvider.isNewSession(sessionId)) {
1085
const folder = vscode.Uri.file(update.value);
1086
if (isEqual(folder, this._selectedRepoForBranches?.repoUri)) {
1087
continue;
1088
}
1089
1090
_sessionBranch.delete(sessionId);
1091
1092
if ((await checkPathExists(folder, this.fileSystem))) {
1093
this.trackLastUsedFolderInWelcomeView(folder);
1094
this.folderRepositoryManager.setNewSessionFolder(sessionId, folder);
1095
1096
// Check if the selected folder is a git repo to show/hide branch dropdown
1097
const repoInfo = await this.folderRepositoryManager.getRepositoryInfo(folder, token);
1098
this._selectedRepoForBranches = repoInfo.repository
1099
? { repoUri: repoInfo.repository, headBranchName: repoInfo.headBranchName }
1100
: undefined;
1101
1102
// When switching to a new repository, we need to update the branch selection for the session. Push an
1103
// update to the session to select the first branch in the new repo and then we will fire an event so
1104
// that the branches from the new repository are loaded in the dropdown.
1105
if (this._selectedRepoForBranches && updates.length === 1) {
1106
const sessionChanges: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [];
1107
1108
const branchItems = await this.getBranchOptionItems();
1109
if (branchItems.length > 0) {
1110
const branchItem = branchItems[0];
1111
_sessionBranch.set(sessionId, branchItem.id);
1112
1113
sessionChanges.push({
1114
optionId: BRANCH_OPTION_ID,
1115
value: {
1116
id: branchItem.id,
1117
name: branchItem.name,
1118
icon: new vscode.ThemeIcon('git-branch')
1119
}
1120
});
1121
}
1122
1123
if (sessionChanges.length > 0) {
1124
this.notifySessionOptionsChange(resource, sessionChanges);
1125
}
1126
1127
// Update all options
1128
triggerProviderOptionsChange = true;
1129
}
1130
} else {
1131
await this.folderMruService.deleteRecentlyUsedFolder(folder);
1132
const message = l10n.t('The path \'{0}\' does not exist on this computer.', folder.fsPath);
1133
vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message });
1134
const defaultRepo = await this.getDefaultUntitledSessionRepositoryOption(sessionId, token);
1135
if (defaultRepo && !isEqual(folder, defaultRepo)) {
1136
this.trackLastUsedFolderInWelcomeView(defaultRepo);
1137
this.folderRepositoryManager.setNewSessionFolder(sessionId, defaultRepo);
1138
const changes: { optionId: string; value: string }[] = [];
1139
changes.push({ optionId: REPOSITORY_OPTION_ID, value: defaultRepo.fsPath });
1140
this.notifySessionOptionsChange(resource, changes);
1141
}
1142
triggerProviderOptionsChange = true;
1143
this._selectedRepoForBranches = undefined;
1144
}
1145
} else if (update.optionId === BRANCH_OPTION_ID) {
1146
if (typeof update.value === 'string' && update.value === _sessionBranch.get(sessionId)) {
1147
continue;
1148
}
1149
_sessionBranch.set(sessionId, update.value);
1150
} else if (update.optionId === ISOLATION_OPTION_ID) {
1151
if (typeof update.value === 'string' && update.value === _sessionIsolation.get(sessionId)) {
1152
continue;
1153
}
1154
_sessionIsolation.set(sessionId, update.value as IsolationMode);
1155
if (typeof update.value === 'string') {
1156
void this.context.globalState.update(LAST_USED_ISOLATION_OPTION_KEY, update.value);
1157
}
1158
triggerProviderOptionsChange = true;
1159
1160
// When switching to worktree, push a default branch selection to the session
1161
// so the branch picker renders. When switching to workspace, remove it.
1162
const sessionChanges: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [];
1163
if (update.value === IsolationMode.Worktree && isBranchOptionFeatureEnabled(this.configurationService)) {
1164
const branchItems = await this.getBranchOptionItems();
1165
if (branchItems.length > 0) {
1166
const branch = _sessionBranch.get(sessionId) ?? branchItems[0].id;
1167
_sessionBranch.set(sessionId, branch);
1168
const branchItem = branchItems.find(b => b.id === branch) ?? branchItems[0];
1169
sessionChanges.push({
1170
optionId: BRANCH_OPTION_ID,
1171
value: {
1172
id: branchItem.id,
1173
name: branchItem.name,
1174
icon: new vscode.ThemeIcon('git-branch')
1175
}
1176
});
1177
}
1178
} else if (update.value === 'workspace') {
1179
_sessionBranch.delete(sessionId);
1180
}
1181
if (sessionChanges.length > 0) {
1182
this.notifySessionOptionsChange(resource, sessionChanges);
1183
}
1184
}
1185
}
1186
const isBranchOptionShow = !!this._selectedRepoForBranches;
1187
if (wasBranchOptionShow !== isBranchOptionShow || triggerProviderOptionsChange) {
1188
this.notifyProviderOptionsChange();
1189
}
1190
}
1191
1192
private _getActiveSessionForResourceId(sessionId: string): ICopilotCLISession | undefined {
1193
return this._activeSessionsById.get(this.sessionItemProvider.untitledSessionIdMapping.get(sessionId) ?? sessionId)
1194
?? this._activeSessionsById.get(sessionId);
1195
}
1196
1197
trackActiveSession(resourceSessionId: string, session: ICopilotCLISession): void {
1198
this._activeSessionsById.set(resourceSessionId, session);
1199
this._activeSessionsById.set(session.sessionId, session);
1200
}
1201
1202
untrackActiveSession(resourceSessionId: string | undefined, session: ICopilotCLISession | undefined, hasPendingRequests: boolean): void {
1203
if (!session || hasPendingRequests) {
1204
return;
1205
}
1206
1207
if (resourceSessionId && this._activeSessionsById.get(resourceSessionId) === session) {
1208
this._activeSessionsById.delete(resourceSessionId);
1209
}
1210
if (this._activeSessionsById.get(session.sessionId) === session) {
1211
this._activeSessionsById.delete(session.sessionId);
1212
}
1213
}
1214
1215
}
1216
1217
function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {
1218
const repositoryUri = isUri(repository) ? repository : repository.rootUri;
1219
const repositoryIcon = isUri(repository) ? 'repo' : repository.kind === 'repository' ? 'repo' : 'archive';
1220
const repositoryName = repositoryUri.path.split('/').pop() ?? repositoryUri.toString();
1221
1222
return {
1223
id: repositoryUri.fsPath,
1224
name: repositoryName,
1225
icon: new vscode.ThemeIcon(repositoryIcon),
1226
default: isDefault
1227
} satisfies vscode.ChatSessionProviderOptionItem;
1228
}
1229
1230
1231
function toWorkspaceFolderOptionItem(workspaceFolderUri: URI, name: string): ChatSessionProviderOptionItem {
1232
return {
1233
id: workspaceFolderUri.fsPath,
1234
name: name,
1235
icon: new vscode.ThemeIcon('folder'),
1236
} satisfies vscode.ChatSessionProviderOptionItem;
1237
}
1238
1239
export class CopilotCLIChatSessionParticipant extends Disposable {
1240
1241
constructor(
1242
private readonly contentProvider: CopilotCLIChatSessionContentProvider,
1243
private readonly promptResolver: CopilotCLIPromptResolver,
1244
private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider,
1245
private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined,
1246
private readonly branchNameGenerator: GitBranchNameGenerator | undefined,
1247
@IGitService private readonly gitService: IGitService,
1248
@ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels,
1249
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
1250
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
1251
@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
1252
@IChatSessionWorktreeCheckpointService private readonly copilotCLIWorktreeCheckpointService: IChatSessionWorktreeCheckpointService,
1253
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
1254
@ITelemetryService private readonly telemetryService: ITelemetryService,
1255
@ILogService private readonly logService: ILogService,
1256
@IPromptsService private readonly promptsService: IPromptsService,
1257
@IChatDelegationSummaryService private readonly chatDelegationSummaryService: IChatDelegationSummaryService,
1258
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
1259
@IConfigurationService private readonly configurationService: IConfigurationService,
1260
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
1261
@IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore,
1262
@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,
1263
@IOctoKitService private readonly octoKitService: IOctoKitService,
1264
) {
1265
super();
1266
}
1267
1268
createHandler(): ChatExtendedRequestHandler {
1269
return this.handleRequest.bind(this);
1270
}
1271
1272
private readonly contextForRequest = new Map<string, {
1273
prompt: string; attachments: Attachment[]; model?: {
1274
model: string;
1275
reasoningEffort?: string | undefined;
1276
};
1277
}>();
1278
1279
/**
1280
* Map to track pending requests for untitled sessions.
1281
* Key = Untitled Session Id
1282
* Value = Map of Request Id to the Promise of the request being handled
1283
* So if we have multiple requests (can happen when steering) for the same untitled session.
1284
*/
1285
private readonly pendingRequestsForUntitledSessions = new Map<string, Map<string, Promise<vscode.ChatResult | void>>>();
1286
1287
/**
1288
* Tracks in-flight requests per session so we can coordinate worktree
1289
* commit / PR handling and cleanup.
1290
*
1291
* We generally cannot have parallel requests for the same session, but when
1292
* steering is involved there can be multiple requests in flight for a
1293
* single session (the original request continues running while steering
1294
* requests are processed). This map records all active requests for each
1295
* session so that any worktree-related actions are deferred until the last
1296
* in-flight request for that session has completed.
1297
*/
1298
private readonly pendingRequestBySession = new Map<string, Set<vscode.ChatRequest>>();
1299
1300
/**
1301
* Outer request handler that supports *yielding* for session steering.
1302
*
1303
* ## How steering works end-to-end
1304
*
1305
* 1. The user sends a message while the session is already processing a
1306
* previous request (status is `InProgress` or `NeedsInput`).
1307
* 2. VS Code signals this by setting `context.yieldRequested = true` on the
1308
* *previous* request's context object.
1309
* 3. This handler polls `context.yieldRequested` every 100 ms. Once detected
1310
* the outer `Promise.race` resolves, returning control to VS Code so it
1311
* can dispatch the new (steering) request.
1312
* 4. Crucially, the inner `handleRequestImpl` promise is **not** cancelled
1313
* or disposed – the original SDK session continues running in the
1314
* background.
1315
* 5. When the new request arrives, `handleRequest` on the underlying
1316
* {@link CopilotCLISession} detects the session is still busy and routes
1317
* through `_handleRequestSteering`, which sends the new prompt with
1318
* `mode: 'immediate'` and waits for both the steering send and the
1319
* original request to complete.
1320
*/
1321
private async handleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> {
1322
const disposables = new DisposableStore();
1323
try {
1324
const handled = this.handleRequestImpl(request, context, stream, token);
1325
if (context.chatSessionContext) {
1326
const { chatSessionContext } = context;
1327
const { resource } = chatSessionContext.chatSessionItem;
1328
const id = SessionIdForCLI.parse(resource);
1329
const isUntitled = this.sessionItemProvider.isNewSession(id);
1330
if (isUntitled) {
1331
const promises = this.pendingRequestsForUntitledSessions.get(id) ?? new Map<string, Promise<vscode.ChatResult | void>>();
1332
promises.set(request.id, handled);
1333
this.pendingRequestsForUntitledSessions.set(id, promises);
1334
}
1335
}
1336
const interval = disposables.add(new IntervalTimer());
1337
const yielded = new DeferredPromise<void>();
1338
interval.cancelAndSet(() => {
1339
if (context.yieldRequested) {
1340
yielded.complete();
1341
}
1342
}, CHECK_FOR_STEERING_DELAY);
1343
1344
return await Promise.race([yielded.p, handled]);
1345
} finally {
1346
disposables.dispose();
1347
}
1348
}
1349
1350
private sendTelemetryForHandleRequest(request: vscode.ChatRequest, context: vscode.ChatContext): void {
1351
const { chatSessionContext } = context;
1352
const hasChatSessionItem = String(!!chatSessionContext?.chatSessionItem);
1353
const isUntitled = String(chatSessionContext?.isUntitled);
1354
const hasDelegatePrompt = String(request.command === 'delegate');
1355
1356
/* __GDPR__
1357
"copilotcli.chat.invoke" : {
1358
"owner": "joshspicer",
1359
"comment": "Event sent when a CopilotCLI chat request is made.",
1360
"chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The unique chat request ID." },
1361
"hasChatSessionItem": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Invoked with a chat session item." },
1362
"isUntitled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the chat session is untitled." },
1363
"hasDelegatePrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the prompt is a /delegate command." }
1364
}
1365
*/
1366
this.telemetryService.sendMSFTTelemetryEvent('copilotcli.chat.invoke', {
1367
chatRequestId: request.id,
1368
hasChatSessionItem,
1369
isUntitled,
1370
hasDelegatePrompt
1371
});
1372
}
1373
1374
private async handleRequestImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> {
1375
let { chatSessionContext } = context;
1376
const disposables = new DisposableStore();
1377
let sessionId: string | undefined = undefined;
1378
let sessionParentId: string | undefined = undefined;
1379
let sessionPermissionLevel: string | undefined = undefined;
1380
let sdkSessionId: string | undefined = undefined;
1381
let activeSession: ICopilotCLISession | undefined;
1382
try {
1383
1384
const initialOptions = chatSessionContext?.initialSessionOptions;
1385
if (initialOptions && chatSessionContext) {
1386
if (initialOptions.length > 0) {
1387
const sessionResource = chatSessionContext.chatSessionItem.resource;
1388
const sessionId = SessionIdForCLI.parse(sessionResource);
1389
for (const opt of initialOptions) {
1390
const value = typeof opt.value === 'string' ? opt.value : opt.value.id;
1391
if (opt.optionId === REPOSITORY_OPTION_ID && value && this.sessionItemProvider.isNewSession(sessionId)) {
1392
this.contentProvider.trackLastUsedFolderInWelcomeView(vscode.Uri.file(value));
1393
this.folderRepositoryManager.setNewSessionFolder(sessionId, vscode.Uri.file(value));
1394
} else if (opt.optionId === BRANCH_OPTION_ID && value) {
1395
_sessionBranch.set(sessionId, value);
1396
} else if (opt.optionId === ISOLATION_OPTION_ID && value) {
1397
_sessionIsolation.set(sessionId, value as IsolationMode);
1398
} else if (opt.optionId === PERMISSION_LEVEL_OPTION_ID && value) {
1399
sessionPermissionLevel = value;
1400
} else if (opt.optionId === PARENT_SESSION_OPTION_ID && value) {
1401
sessionParentId = value;
1402
}
1403
}
1404
}
1405
}
1406
1407
if (!chatSessionContext && SessionIdForCLI.isCLIResource(request.sessionResource)) {
1408
/**
1409
* Work around for bug in core, context cannot be empty, but it is.
1410
* This happens when we delegate from another chat and start a background agent,
1411
* but for some reason the context is lost when the request is actually handled, as a result it gets treated as a new delegating request.
1412
* & then we end up in an inifinite loop of delegating requests.
1413
*/
1414
const id = SessionIdForCLI.parse(request.sessionResource);
1415
if (this.contextForRequest.has(id)) {
1416
chatSessionContext = {
1417
chatSessionItem: {
1418
label: request.prompt,
1419
resource: request.sessionResource,
1420
},
1421
isUntitled: false,
1422
initialSessionOptions: undefined,
1423
inputState: {
1424
groups: [],
1425
sessionResource: undefined,
1426
onDidDispose: Event.None,
1427
onDidChange: Event.None
1428
}
1429
};
1430
context = {
1431
chatSessionContext,
1432
history: [],
1433
yieldRequested: false
1434
} satisfies vscode.ChatContext;
1435
}
1436
}
1437
1438
this.sendTelemetryForHandleRequest(request, context);
1439
1440
const [authInfo,] = await Promise.all([this.copilotCLISDK.getAuthInfo().catch((ex) => this.logService.error(ex, 'Authorization failed')), this.lockRepoOptionForSession(context, token)]);
1441
if (!authInfo) {
1442
this.logService.error(`Authorization failed`);
1443
throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));
1444
}
1445
if ((authInfo.type === 'token' && !authInfo.token) && !this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl)) {
1446
this.logService.error(`Authorization failed`);
1447
throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));
1448
}
1449
1450
if (!chatSessionContext || !SessionIdForCLI.isCLIResource(request.sessionResource)) {
1451
// Delegating from another chat session
1452
return await this.handleDelegationFromAnotherChat(request, undefined, request.references, context, stream, authInfo, token);
1453
}
1454
1455
const { resource } = chatSessionContext.chatSessionItem;
1456
const id = SessionIdForCLI.parse(resource);
1457
sessionId = id;
1458
const isUntitled = chatSessionContext.isUntitled;
1459
const invalidSessionMessage = _invalidCopilotCLISessionIdsWithErrorMessage.get(id);
1460
if (invalidSessionMessage) {
1461
const { issueUrl } = getSessionLoadFailureIssueInfo(invalidSessionMessage);
1462
const warningMessage = new vscode.MarkdownString();
1463
warningMessage.appendMarkdown(l10n.t({
1464
message: "Failed loading this session. If this issue persists, please [report an issue]({issueUrl}). \nError: ",
1465
args: { issueUrl },
1466
comment: [`{Locked=']({'}`]
1467
}));
1468
warningMessage.appendText(invalidSessionMessage);
1469
stream.warning(warningMessage);
1470
return {};
1471
}
1472
1473
// Check if we have context stored for this request
1474
const contextForRequest = this.contextForRequest.get(sessionId);
1475
this.contextForRequest.delete(sessionId);
1476
const [model, agent] = await Promise.all([
1477
contextForRequest?.model ? Promise.resolve(contextForRequest.model) : this.getModelId(request, token),
1478
this.getAgent(id, request, token),
1479
]);
1480
1481
const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined);
1482
const fakeContext: vscode.ChatContext = {
1483
history: [requestTurn],
1484
yieldRequested: false,
1485
};
1486
const newBranch = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : undefined;
1487
1488
const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId, permissionLevel: sessionPermissionLevel }, disposables, token);
1489
const session = sessionResult.session;
1490
if (session) {
1491
disposables.add(session);
1492
}
1493
if (!session || token.isCancellationRequested) {
1494
// If user didn't trust, then reset the session options to make it read-write.
1495
if (!sessionResult.trusted) {
1496
await this.unlockRepoOptionForSession(context, token);
1497
}
1498
return {};
1499
}
1500
1501
if (context.history.length === 0) {
1502
// Create baseline checkpoint when handling the first request
1503
await this.copilotCLIWorktreeCheckpointService.handleRequest(session.object.sessionId);
1504
}
1505
1506
sdkSessionId = session.object.sessionId;
1507
activeSession = session.object;
1508
this.contentProvider.trackActiveSession(sessionId, activeSession);
1509
const modeInstructions = this.createModeInstructions(request);
1510
this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
1511
1512
// Lock the repo option with more accurate information.
1513
// Previously we just updated it with details of the folder.
1514
// If user has selected a repo, then update with repo information (right icons, etc).
1515
if (isUntitled) {
1516
void this.lockRepoOptionForSession(context, token);
1517
this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token)
1518
.then(title => title ? this.sessionService.updateSessionSummary(session.object.sessionId, title) : undefined)
1519
.catch(ex => this.logService.error(ex, 'Failed to generate custom session title'));
1520
}
1521
const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set<vscode.ChatRequest>();
1522
requestsForSession.add(request);
1523
this.pendingRequestBySession.set(session.object.sessionId, requestsForSession);
1524
1525
if (request.command === 'delegate') {
1526
await this.handleDelegationToCloud(session.object, request, context, stream, token);
1527
} else if (contextForRequest) {
1528
// This is a request that was created in createCLISessionAndSubmitRequest with attachments already resolved.
1529
const { prompt, attachments } = contextForRequest;
1530
await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);
1531
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
1532
} else if (request.command && !request.prompt && !isUntitled) {
1533
const input = (copilotCLICommands as readonly string[]).includes(request.command)
1534
? { command: request.command as CopilotCLICommand, prompt: '' }
1535
: { prompt: `/${request.command}` };
1536
await session.object.handleRequest(request, input, [], model, authInfo, token);
1537
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
1538
} else if (request.prompt && Object.values(builtinSlashSCommands).some(command => request.prompt.startsWith(command))) {
1539
// Sessions app built-in slash commands
1540
const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token);
1541
await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);
1542
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
1543
} else {
1544
// Construct the full prompt with references to be sent to CLI.
1545
const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token);
1546
await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);
1547
await this.commitWorktreeChangesIfNeeded(request, session.object, token);
1548
}
1549
1550
// Build the result before the untitled-session swap below. After the swap,
1551
// the chat UI reloads history from the SDK and discards the in-memory
1552
// result, which would drop our `details` field on the first request.
1553
const models = await this.copilotCLIModels.getModels().catch(ex => {
1554
this.logService.error(ex, 'Failed to get models');
1555
return [];
1556
});
1557
const modelInfo = models.find(m => m.id === model?.model);
1558
const result: vscode.ChatResult = modelInfo
1559
? { details: formatModelDetails(modelInfo) }
1560
: {};
1561
1562
if (isUntitled && !token.isCancellationRequested) {
1563
// Its possible the user tried steering, in that case, we should NOT swap the session item because the session.
1564
// Else the messages may get lost (wait CHECK_FOR_STEERING_DELAYms to check if we have pending steering requests)
1565
await new Promise<void>(resolve => disposableTimeout(() => resolve(), CHECK_FOR_STEERING_DELAY, this._store));
1566
const pendingRequests = this.pendingRequestsForUntitledSessions.get(id);
1567
if (pendingRequests) {
1568
pendingRequests.delete(request.id);
1569
// If we have more requests, that means we had the original request as well as at least one another steering request.
1570
// Lets not swap anything here, until all pending requests have been completed.
1571
if (pendingRequests.size > 0) {
1572
return result;
1573
}
1574
}
1575
1576
// Delete old information stored for untitled session id.
1577
_sessionBranch.delete(id);
1578
_sessionIsolation.delete(id);
1579
this.sessionItemProvider.untitledSessionIdMapping.delete(id);
1580
this.sessionItemProvider.sdkToUntitledUriMapping.delete(session.object.sessionId);
1581
this.folderRepositoryManager.deleteNewSessionFolder(id);
1582
this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { resource: SessionIdForCLI.getResource(session.object.sessionId), label: request.prompt });
1583
}
1584
1585
return result;
1586
} catch (ex) {
1587
if (isCancellationError(ex)) {
1588
return {};
1589
}
1590
throw ex;
1591
}
1592
finally {
1593
if (sdkSessionId) {
1594
const requestsForSession = this.pendingRequestBySession.get(sdkSessionId);
1595
if (requestsForSession) {
1596
requestsForSession.delete(request);
1597
if (requestsForSession.size === 0) {
1598
this.pendingRequestBySession.delete(sdkSessionId);
1599
}
1600
}
1601
}
1602
this.contentProvider.untrackActiveSession(sessionId, activeSession, sdkSessionId ? this.pendingRequestBySession.has(sdkSessionId) : false);
1603
if (chatSessionContext?.chatSessionItem.resource) {
1604
this.sessionItemProvider.notifySessionsChange();
1605
}
1606
disposables.dispose();
1607
}
1608
}
1609
1610
private async lockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) {
1611
const { chatSessionContext } = context;
1612
if (!chatSessionContext?.isUntitled) {
1613
return;
1614
}
1615
const { resource } = chatSessionContext.chatSessionItem;
1616
// If we have a real session id that was mapped to this untitled session, then use that.
1617
// This way we can get the latest information associated with the real session.
1618
const parsedId = SessionIdForCLI.parse(resource);
1619
const id = this.sessionItemProvider.untitledSessionIdMapping.get(parsedId) ?? parsedId;
1620
const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token);
1621
if (folderInfo.folder) {
1622
const folderName = basename(folderInfo.folder);
1623
const option = folderInfo.repository ? toRepositoryOptionItem(folderInfo.repository) : toWorkspaceFolderOptionItem(folderInfo.folder, folderName);
1624
const changes: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [
1625
{ optionId: REPOSITORY_OPTION_ID, value: { ...option, locked: true } }
1626
];
1627
// Also lock the branch option
1628
const selectedBranch = folderInfo.worktreeProperties?.branchName ?? _sessionBranch.get(id);
1629
if (selectedBranch && isBranchOptionFeatureEnabled(this.configurationService)) {
1630
changes.push({
1631
optionId: BRANCH_OPTION_ID,
1632
value: {
1633
id: selectedBranch,
1634
name: selectedBranch,
1635
icon: new vscode.ThemeIcon('git-branch'),
1636
locked: true
1637
}
1638
});
1639
}
1640
// Also lock the isolation option if set
1641
const selectedIsolation = _sessionIsolation.get(id);
1642
if (selectedIsolation && isIsolationOptionFeatureEnabled(this.configurationService)) {
1643
changes.push({
1644
optionId: ISOLATION_OPTION_ID,
1645
value: {
1646
id: selectedIsolation,
1647
name: selectedIsolation === IsolationMode.Worktree
1648
? l10n.t('Worktree')
1649
: l10n.t('Workspace'),
1650
icon: new vscode.ThemeIcon(selectedIsolation === IsolationMode.Worktree ? 'worktree' : 'folder'),
1651
locked: true
1652
}
1653
});
1654
}
1655
this.contentProvider.notifySessionOptionsChange(resource, changes);
1656
}
1657
}
1658
1659
private async unlockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) {
1660
const { chatSessionContext } = context;
1661
if (!chatSessionContext?.isUntitled) {
1662
return;
1663
}
1664
const { resource } = chatSessionContext.chatSessionItem;
1665
const id = SessionIdForCLI.parse(resource);
1666
const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token);
1667
if (folderInfo.folder) {
1668
const option = folderInfo.repository?.fsPath ?? folderInfo.folder.fsPath;
1669
const changes: { optionId: string; value: string }[] = [
1670
{ optionId: REPOSITORY_OPTION_ID, value: option }
1671
];
1672
// Also unlock the branch option if a branch was selected
1673
const selectedBranch = _sessionBranch.get(id);
1674
if (selectedBranch && isBranchOptionFeatureEnabled(this.configurationService)) {
1675
changes.push({ optionId: BRANCH_OPTION_ID, value: selectedBranch });
1676
}
1677
// Also unlock the isolation option if set
1678
const selectedIsolation = _sessionIsolation.get(id);
1679
if (selectedIsolation && isIsolationOptionFeatureEnabled(this.configurationService)) {
1680
changes.push({ optionId: ISOLATION_OPTION_ID, value: selectedIsolation });
1681
}
1682
this.contentProvider.notifySessionOptionsChange(resource, changes);
1683
}
1684
}
1685
1686
private async commitWorktreeChangesIfNeeded(request: vscode.ChatRequest, session: ICopilotCLISession, token: vscode.CancellationToken): Promise<void> {
1687
const pendingRequests = this.pendingRequestBySession.get(session.sessionId);
1688
if (pendingRequests && pendingRequests.size > 1) {
1689
// We still have pending requests for this session, which means the user has done some steering.
1690
// Wait for all requests to complete, the last request to complete will handle the commit.
1691
pendingRequests.delete(request);
1692
return;
1693
}
1694
1695
if (token.isCancellationRequested) {
1696
pendingRequests?.delete(request);
1697
return;
1698
}
1699
1700
try {
1701
if (session.status === vscode.ChatSessionStatus.Completed) {
1702
const workingDirectory = getWorkingDirectory(session.workspace);
1703
if (isIsolationEnabled(session.workspace)) {
1704
// When isolation is enabled and we are using a git worktree, so we commit
1705
// all the changes in the worktree directory when the session is completed.
1706
// Note that if the worktree supports checkpoints, then the commit will be
1707
// done in the checkpoint so that users can easily see the changes made in
1708
// the worktree and also revert back if needed.
1709
await this.copilotCLIWorktreeManagerService.handleRequestCompleted(session.sessionId);
1710
} else if (workingDirectory) {
1711
// When isolation is not enabled, we are operating in the workspace directly,
1712
// so we stage all the changes in the workspace directory when the session is
1713
// completed
1714
await this.workspaceFolderService.handleRequestCompleted(session.sessionId);
1715
}
1716
1717
// Create checkpoint - we create a checkpoint for the worktree changes so that users
1718
// can easily see the changes made in the worktree and also revert back if needed. This
1719
// is used if worktree isolation is enabled, and auto-commit is disabled or workspace
1720
// isolation is enabled.
1721
await this.copilotCLIWorktreeCheckpointService.handleRequestCompleted(session.sessionId, request.id);
1722
if (workingDirectory) {
1723
void clearChangesCacheForAffectedSessions(workingDirectory, [session.sessionId], this.logService, this.chatSessionMetadataStore, this.workspaceFolderService, this.copilotCLIWorktreeManagerService, this.sessionItemProvider).catch(ex => this.logService.error(ex, 'Failed to clear changes cache after request completion'));
1724
}
1725
}
1726
1727
void this.handlePullRequestCreated(session).catch(ex => this.logService.error(ex, 'Failed to handle pull request creation'));
1728
} finally {
1729
pendingRequests?.delete(request);
1730
}
1731
}
1732
1733
private static readonly _PR_DETECTION_RETRY_COUNT = 5;
1734
private static readonly _PR_DETECTION_INITIAL_DELAY_MS = 2_000;
1735
1736
private async handlePullRequestCreated(session: ICopilotCLISession): Promise<void> {
1737
const sessionId = session.sessionId;
1738
let prUrl = session.createdPullRequestUrl;
1739
let prState = '';
1740
1741
this.logService.debug(`[CopilotCLIChatSessionParticipant] handlePullRequestCreated for ${sessionId}: createdPullRequestUrl=${prUrl ?? 'none'}`);
1742
1743
const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1744
1745
if (!worktreeProperties || worktreeProperties.version !== 2) {
1746
return;
1747
}
1748
1749
if (!prUrl) {
1750
// Only attempt retry detection if the session has v2 worktree properties
1751
// with branch info — v1 worktrees can't store PR URLs, and sessions
1752
// without worktree properties have nothing to look up.
1753
if (worktreeProperties.branchName && worktreeProperties.repositoryPath) {
1754
this.logService.debug(`[CopilotCLIChatSessionParticipant] No PR URL from session, attempting retry detection for ${sessionId}, branch=${worktreeProperties.branchName}`);
1755
const prResult = await this.detectPullRequestWithRetry(sessionId);
1756
prUrl = prResult?.url;
1757
prState = prResult?.state ?? prUrl ? 'open' : '';
1758
} else {
1759
this.logService.debug(`[CopilotCLIChatSessionParticipant] Skipping retry detection for ${sessionId}: branch=${worktreeProperties.branchName ?? 'none'}, repoPath=${!!worktreeProperties.repositoryPath}`);
1760
}
1761
}
1762
1763
if (!prUrl) {
1764
this.logService.debug(`[CopilotCLIChatSessionParticipant] No PR detected for ${sessionId} after all attempts`);
1765
return;
1766
}
1767
1768
try {
1769
await this.copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, {
1770
...worktreeProperties,
1771
pullRequestUrl: prUrl,
1772
pullRequestState: prState,
1773
changes: undefined,
1774
});
1775
this.sessionItemProvider.notifySessionsChange();
1776
} catch (error) {
1777
const err = error instanceof Error ? error : new Error(String(error));
1778
this.logService.error(err, `Failed to persist pull request metadata for session ${sessionId}`);
1779
}
1780
}
1781
1782
/**
1783
* Attempts to detect a pull request for a freshly-completed session using
1784
* exponential backoff. The GitHub API may not have indexed the PR immediately
1785
* after `gh pr create` returns, so we retry with increasing delays:
1786
* attempt 1: 2s, attempt 2: 4s, attempt 3: 8s.
1787
*/
1788
private async detectPullRequestWithRetry(sessionId: string): Promise<{ url: string; state: string } | undefined> {
1789
const maxRetries = CopilotCLIChatSessionParticipant._PR_DETECTION_RETRY_COUNT;
1790
const initialDelay = CopilotCLIChatSessionParticipant._PR_DETECTION_INITIAL_DELAY_MS;
1791
1792
for (let attempt = 0; attempt < maxRetries; attempt++) {
1793
const delay = initialDelay * Math.pow(2, attempt);
1794
this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detection retry for ${sessionId}: attempt ${attempt + 1}/${maxRetries}, waiting ${delay}ms`);
1795
await new Promise<void>(resolve => setTimeout(resolve, delay));
1796
1797
const prResult = await this.detectPullRequestForSession(sessionId);
1798
if (prResult) {
1799
this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detected on attempt ${attempt + 1} for ${sessionId}: url=${prResult.url}, state=${prResult.state}`);
1800
return prResult;
1801
}
1802
}
1803
1804
this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detection exhausted all ${maxRetries} retries for ${sessionId}`);
1805
return undefined;
1806
}
1807
1808
/**
1809
* Queries the GitHub API to find a pull request whose head branch matches the
1810
* session's worktree branch. This covers cases where the MCP tool failed to
1811
* report a PR URL, or the user created the PR externally (e.g., via github.com).
1812
*/
1813
private async detectPullRequestForSession(sessionId: string): Promise<{ url: string; state: string } | undefined> {
1814
try {
1815
const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
1816
if (!worktreeProperties?.branchName || !worktreeProperties.repositoryPath) {
1817
this.logService.debug(`[CopilotCLIChatSessionParticipant] detectPullRequestForSession: missing worktree info for ${sessionId}, branch=${worktreeProperties?.branchName ?? 'none'}, repoPath=${!!worktreeProperties?.repositoryPath}`);
1818
return undefined;
1819
}
1820
1821
return await detectPullRequestFromGitHubAPI(
1822
worktreeProperties.branchName,
1823
worktreeProperties.repositoryPath,
1824
this.gitService,
1825
this.octoKitService,
1826
this.logService,
1827
);
1828
} catch (error) {
1829
this.logService.debug(`[CopilotCLIChatSessionParticipant] Failed to detect pull request via GitHub API: ${error instanceof Error ? error.message : String(error)}`);
1830
return undefined;
1831
}
1832
}
1833
1834
/**
1835
* Gets the agent to be used.
1836
* If the request has a prompt file (modeInstructions2) that specifies an agent, uses that agent.
1837
* If the prompt file specifies tools, those tools override the agent's default tools.
1838
* Otherwise returns undefined (no agent).
1839
*/
1840
private async getAgent(sessionId: string | undefined, request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<SweCustomAgent | undefined> {
1841
// If we have a prompt file that specifies an agent or tools, use that.
1842
if (request?.modeInstructions2) {
1843
const customAgent = request.modeInstructions2.uri ? await this.copilotCLIAgents.resolveAgent(request.modeInstructions2.uri.toString()) : await this.copilotCLIAgents.resolveAgent(request.modeInstructions2.name);
1844
if (customAgent) {
1845
const tools = (request.modeInstructions2.toolReferences || []).map(t => t.name);
1846
if (tools.length > 0) {
1847
customAgent.tools = tools;
1848
}
1849
return customAgent;
1850
}
1851
}
1852
// If not found, don't use any agent, default to empty agent.
1853
return undefined;
1854
}
1855
1856
private async getPromptInfoFromRequest(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise<ParsedPromptFile | undefined> {
1857
const promptFile = new ChatVariablesCollection(request.references).find(isPromptFile);
1858
if (!promptFile || !URI.isUri(promptFile.reference.value)) {
1859
return undefined;
1860
}
1861
try {
1862
return await this.promptsService.parseFile(promptFile.reference.value, token);
1863
} catch (ex) {
1864
this.logService.error(`Failed to parse the prompt file: ${promptFile.reference.value.toString()}`, ex);
1865
return undefined;
1866
}
1867
}
1868
1869
private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise<string | undefined>; sessionParentId?: string; permissionLevel?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; trusted: boolean }> {
1870
const { resource } = chatSessionContext.chatSessionItem;
1871
const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource));
1872
const id = existingSessionId ?? SessionIdForCLI.parse(resource);
1873
const isNewSession = chatSessionContext.isUntitled && !existingSessionId;
1874
1875
const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, stream, request.toolInvocationToken, token, options.newBranch);
1876
const workingDirectory = getWorkingDirectory(workspaceInfo);
1877
const worktreeProperties = workspaceInfo.worktreeProperties;
1878
if (cancelled || token.isCancellationRequested) {
1879
return { session: undefined, trusted };
1880
}
1881
1882
const model = options.model;
1883
const agent = options.agent;
1884
const debugTargetSessionIds = extractDebugTargetSessionIds(request.references);
1885
const mcpServerMappings = buildMcpServerMappings(request.tools);
1886
const session = isNewSession ?
1887
await this.sessionService.createSession({ model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings, sessionParentId: options.sessionParentId }, token) :
1888
await this.sessionService.getSession({ sessionId: id, model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token);
1889
this.sessionItemProvider.notifySessionsChange();
1890
// TODO @DonJayamanne We need to refresh to add this new session, but we need a label.
1891
// So when creating a session we need a dummy label (or an initial prompt).
1892
1893
if (!session) {
1894
stream.warning(l10n.t('Chat session not found.'));
1895
return { session: undefined, trusted };
1896
}
1897
this.logService.info(`Using Copilot CLI session: ${session.object.sessionId} (isNewSession: ${isNewSession}, isolationEnabled: ${isIsolationEnabled(workspaceInfo)}, workingDirectory: ${workingDirectory}, worktreePath: ${worktreeProperties?.worktreePath})`);
1898
if (isNewSession) {
1899
this.sessionItemProvider.untitledSessionIdMapping.set(id, session.object.sessionId);
1900
this.sessionItemProvider.sdkToUntitledUriMapping.set(session.object.sessionId, resource);
1901
if (worktreeProperties) {
1902
void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties);
1903
}
1904
}
1905
const sessionWorkingDirectory = getWorkingDirectory(session.object.workspace);
1906
if (sessionWorkingDirectory && !isIsolationEnabled(session.object.workspace)) {
1907
void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, sessionWorkingDirectory.fsPath, session.object.workspace.repositoryProperties);
1908
}
1909
disposables.add(session.object.attachStream(stream));
1910
const permissionLevel = request.permissionLevel ?? options.permissionLevel;
1911
session.object.setPermissionLevel(permissionLevel);
1912
1913
return { session, trusted };
1914
}
1915
1916
private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<{ model: string; reasoningEffort?: string } | undefined> {
1917
const promptFile = request ? await this.getPromptInfoFromRequest(request, token) : undefined;
1918
const model = promptFile?.header?.model ? await getModelFromPromptFile(promptFile.header.model, this.copilotCLIModels) : undefined;
1919
if (token.isCancellationRequested) {
1920
return undefined;
1921
}
1922
if (model) {
1923
return { model };
1924
}
1925
// Get model from request.
1926
const preferredModelInRequest = request?.model?.id ? await this.copilotCLIModels.resolveModel(request.model.id) : undefined;
1927
if (preferredModelInRequest) {
1928
const reasoningEffort = isReasoningEffortFeatureEnabled(this.configurationService) ? request?.modelConfiguration?.[COPILOT_CLI_REASONING_EFFORT_PROPERTY] : undefined;
1929
return {
1930
model: preferredModelInRequest,
1931
reasoningEffort: typeof reasoningEffort === 'string' && reasoningEffort ? reasoningEffort : undefined
1932
};
1933
}
1934
const defaultModel = await this.copilotCLIModels.getDefaultModel();
1935
if (!defaultModel) {
1936
return undefined;
1937
}
1938
return { model: defaultModel };
1939
}
1940
1941
private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {
1942
if (!this.cloudSessionProvider) {
1943
stream.warning(l10n.t('No cloud agent available'));
1944
return;
1945
}
1946
1947
// Check for uncommitted changes
1948
const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.sessionId);
1949
const repositoryPath = worktreeProperties?.repositoryPath ? Uri.file(worktreeProperties.repositoryPath) : getWorkingDirectory(session.workspace);
1950
const repository = repositoryPath ? await this.gitService.getRepository(repositoryPath) : undefined;
1951
const hasChanges = (repository?.changes?.indexChanges && repository.changes.indexChanges.length > 0);
1952
1953
if (hasChanges) {
1954
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.'));
1955
}
1956
1957
const prInfo = await this.cloudSessionProvider.delegate(request, stream, context, token, { prompt: request.prompt, chatContext: context });
1958
await this.recordPushToSession(session, `/delegate ${request.prompt}`, prInfo);
1959
1960
}
1961
1962
private async getOrInitializeWorkingDirectory(
1963
chatSessionContext: vscode.ChatSessionContext | undefined,
1964
stream: vscode.ChatResponseStream,
1965
toolInvocationToken: vscode.ChatParticipantToolToken,
1966
token: vscode.CancellationToken,
1967
newBranch?: Promise<string | undefined>
1968
): Promise<{
1969
workspaceInfo: IWorkspaceInfo;
1970
cancelled: boolean;
1971
trusted: boolean;
1972
}> {
1973
let folderInfo: FolderRepositoryInfo;
1974
if (chatSessionContext) {
1975
const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource));
1976
const id = existingSessionId ?? SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource);
1977
const isNewSession = chatSessionContext.isUntitled && !existingSessionId;
1978
1979
if (isNewSession) {
1980
// Use FolderRepositoryManager to initialize folder/repository with worktree creation
1981
const branch = _sessionBranch.get(id);
1982
const isolation = _sessionIsolation.get(id) ?? undefined;
1983
folderInfo = await this.folderRepositoryManager.initializeFolderRepository(id, { stream, toolInvocationToken, branch: branch ?? undefined, isolation, folder: undefined, newBranch }, token);
1984
} else {
1985
// Existing session - use getFolderRepository for resolution with trust check
1986
folderInfo = await this.folderRepositoryManager.getFolderRepository(id, { promptForTrust: true, stream }, token);
1987
}
1988
} else {
1989
// No chat session context (e.g., delegation) - initialize with active repository
1990
folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation: undefined, folder: undefined }, token);
1991
}
1992
1993
if (folderInfo.trusted === false || folderInfo.cancelled) {
1994
return { workspaceInfo: emptyWorkspaceInfo(), cancelled: true, trusted: folderInfo.trusted !== false };
1995
}
1996
1997
const workspaceInfo = Object.assign({}, folderInfo);
1998
return { workspaceInfo, cancelled: false, trusted: true };
1999
}
2000
2001
private createModeInstructions(request: vscode.ChatRequest): StoredModeInstructions | undefined {
2002
return request.modeInstructions2 ? {
2003
uri: request.modeInstructions2.uri?.toString(),
2004
name: request.modeInstructions2.name,
2005
content: request.modeInstructions2.content,
2006
metadata: request.modeInstructions2.metadata,
2007
isBuiltin: request.modeInstructions2.isBuiltin,
2008
} : undefined;
2009
2010
}
2011
private async handleDelegationFromAnotherChat(
2012
request: vscode.ChatRequest,
2013
userPrompt: string | undefined,
2014
otherReferences: readonly vscode.ChatPromptReference[] | undefined,
2015
context: vscode.ChatContext,
2016
stream: vscode.ChatResponseStream,
2017
authInfo: NonNullable<SessionOptions['authInfo']>,
2018
token: vscode.CancellationToken
2019
): Promise<vscode.ChatResult> {
2020
let summary: string | undefined;
2021
const requestPromptPromise = (async () => {
2022
if (this.hasHistoryToSummarize(context.history)) {
2023
stream.progress(l10n.t('Analyzing chat history'));
2024
summary = await this.chatDelegationSummaryService.summarize(context, token);
2025
summary = summary ? `**Summary**\n${summary}` : undefined;
2026
}
2027
2028
// Give priority to userPrompt if provided (e.g., from confirmation metadata)
2029
userPrompt = userPrompt || request.prompt;
2030
return summary ? `${userPrompt}\n${summary}` : userPrompt;
2031
})();
2032
2033
const [{ workspaceInfo, cancelled }, model, agent] = await Promise.all([
2034
this.getOrInitializeWorkingDirectory(undefined, stream, request.toolInvocationToken, token),
2035
this.getModelId(request, token), // prefer model in request, as we're delegating from another session here.
2036
this.getAgent(undefined, undefined, token)
2037
]);
2038
2039
if (cancelled || token.isCancellationRequested) {
2040
stream.markdown(l10n.t('Copilot CLI delegation cancelled.'));
2041
return {};
2042
}
2043
const workingDirectory = getWorkingDirectory(workspaceInfo);
2044
const worktreeProperties = workspaceInfo.worktreeProperties;
2045
const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token);
2046
2047
const mcpServerMappings = buildMcpServerMappings(request.tools);
2048
const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model: model?.model, reasoningEffort: model?.reasoningEffort, mcpServerMappings }, token);
2049
const modeInstructions = this.createModeInstructions(request);
2050
this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
2051
if (summary) {
2052
const summaryRef = await this.chatDelegationSummaryService.trackSummaryUsage(session.object.sessionId, summary);
2053
if (summaryRef) {
2054
references.push(summaryRef);
2055
}
2056
}
2057
// Do not await, we want this code path to be as fast as possible.
2058
if (worktreeProperties) {
2059
void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties);
2060
}
2061
if (workingDirectory && !isIsolationEnabled(workspaceInfo)) {
2062
void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, workingDirectory.fsPath, workspaceInfo.repositoryProperties);
2063
}
2064
2065
this.contextForRequest.set(session.object.sessionId, { prompt, attachments, model });
2066
this.sessionItemProvider.notifySessionsChange();
2067
// TODO @DonJayamanne I don't think we need to refresh the list of session here just yet, or perhaps we do,
2068
// Same as getOrCreate session, we need a dummy title or the initial prompt to show in the sessions list.
2069
void vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2070
resource: SessionIdForCLI.getResource(session.object.sessionId),
2071
prompt: userPrompt || request.prompt,
2072
attachedContext: references.map(ref => convertReferenceToVariable(ref, attachments))
2073
});
2074
2075
stream.markdown(l10n.t('A Copilot CLI session has begun working on your request. Follow its progress in the sessions list.'));
2076
2077
return {};
2078
}
2079
2080
private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean {
2081
if (!history || history.length === 0) {
2082
return false;
2083
}
2084
const allResponsesEmpty = history.every(turn => {
2085
if (turn instanceof vscode.ChatResponseTurn) {
2086
return turn.response.length === 0;
2087
}
2088
return true;
2089
});
2090
return !allResponsesEmpty;
2091
}
2092
2093
private async recordPushToSession(
2094
session: ICopilotCLISession,
2095
userPrompt: string,
2096
prInfo: vscode.ChatResponsePullRequestPart
2097
): Promise<void> {
2098
// Add user message event
2099
session.addUserMessage(userPrompt);
2100
2101
// Add assistant message event with embedded PR metadata
2102
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)}"/>`;
2103
session.addUserAssistantMessage(assistantMessage);
2104
}
2105
}
2106
2107
export function registerCLIChatCommands(
2108
copilotcliSessionItemProvider: CopilotCLIChatSessionItemProvider,
2109
copilotCLISessionService: ICopilotCLISessionService,
2110
copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
2111
gitService: IGitService,
2112
gitExtensionService: IGitExtensionService,
2113
toolsService: IToolsService,
2114
copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService,
2115
contentProvider: CopilotCLIChatSessionContentProvider,
2116
folderRepositoryManager: IFolderRepositoryManager,
2117
cliFolderMruService: IChatFolderMruService,
2118
envService: INativeEnvService,
2119
fileSystemService: IFileSystemService,
2120
logService: ILogService
2121
): IDisposable {
2122
const disposableStore = new DisposableStore();
2123
async function deleteSessionById(sessionId: string): Promise<void> {
2124
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2125
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
2126
2127
await copilotCLISessionService.deleteSession(sessionId);
2128
await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(sessionId);
2129
2130
if (worktreePath) {
2131
const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false);
2132
if (worktreeExists) {
2133
try {
2134
const repository = worktree ? await gitService.getRepository(vscode.Uri.file(worktree.repositoryPath), true) : undefined;
2135
if (!repository) {
2136
throw new Error(l10n.t('No active repository found to delete worktree.'));
2137
}
2138
await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath);
2139
} catch (error) {
2140
vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error)));
2141
}
2142
}
2143
}
2144
}
2145
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.delete', async (sessionItem?: vscode.ChatSessionItem) => {
2146
if (sessionItem?.resource) {
2147
const id = SessionIdForCLI.parse(sessionItem.resource);
2148
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2149
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
2150
2151
const confirmMessage = worktreePath
2152
? l10n.t('Are you sure you want to delete the session and its associated worktree?')
2153
: l10n.t('Are you sure you want to delete the session?');
2154
2155
const deleteLabel = l10n.t('Delete');
2156
const result = await vscode.window.showWarningMessage(
2157
confirmMessage,
2158
{ modal: true },
2159
deleteLabel
2160
);
2161
2162
if (result === deleteLabel) {
2163
await deleteSessionById(sessionId);
2164
copilotcliSessionItemProvider.notifySessionsChange();
2165
}
2166
}
2167
}));
2168
disposableStore.add(vscode.commands.registerCommand('agents.github.copilot.cli.deleteSessions', async (sessionItems?: vscode.ChatSessionItem[], options?: { skipConfirmation?: boolean }) => {
2169
if (!sessionItems?.length) {
2170
return;
2171
}
2172
2173
if (!options?.skipConfirmation) {
2174
const deleteLabel = l10n.t('Delete');
2175
const confirmMessage = sessionItems.length === 1
2176
? l10n.t('Are you sure you want to delete the session?')
2177
: l10n.t('Are you sure you want to delete {0} sessions?', sessionItems.length);
2178
const result = await vscode.window.showWarningMessage(
2179
confirmMessage,
2180
{ modal: true },
2181
deleteLabel
2182
);
2183
if (result !== deleteLabel) {
2184
return;
2185
}
2186
}
2187
2188
for (const sessionItem of sessionItems) {
2189
if (sessionItem.resource) {
2190
const id = SessionIdForCLI.parse(sessionItem.resource);
2191
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2192
await deleteSessionById(sessionId);
2193
}
2194
}
2195
2196
copilotcliSessionItemProvider.notifySessionsChange();
2197
}));
2198
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.resumeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => {
2199
if (sessionItem?.resource) {
2200
await copilotcliSessionItemProvider.resumeCopilotCLISessionInTerminal(sessionItem);
2201
}
2202
}));
2203
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.rename', async (sessionItem?: vscode.ChatSessionItem) => {
2204
if (!sessionItem?.resource) {
2205
return;
2206
}
2207
const newTitle = await vscode.window.showInputBox({
2208
prompt: l10n.t('New agent session title'),
2209
value: sessionItem.label,
2210
validateInput: value => {
2211
if (!value.trim()) {
2212
return l10n.t('Title cannot be empty');
2213
}
2214
return undefined;
2215
}
2216
});
2217
if (newTitle) {
2218
const trimmedTitle = newTitle.trim();
2219
if (trimmedTitle) {
2220
const id = SessionIdForCLI.parse(sessionItem.resource);
2221
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2222
await copilotCLISessionService.renameSession(sessionId, trimmedTitle);
2223
copilotcliSessionItemProvider.notifySessionsChange();
2224
}
2225
}
2226
}));
2227
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.setTitle', async (sessionItem?: vscode.ChatSessionItem, title?: string) => {
2228
if (!sessionItem?.resource || !title) {
2229
return;
2230
}
2231
const trimmedTitle = title.trim();
2232
if (trimmedTitle) {
2233
const id = SessionIdForCLI.parse(sessionItem.resource);
2234
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2235
await copilotCLISessionService.renameSession(sessionId, trimmedTitle);
2236
copilotcliSessionItemProvider.notifySessionsChange();
2237
}
2238
}));
2239
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.newSession', async () => {
2240
await copilotcliSessionItemProvider.createCopilotCLITerminal('editor', l10n.t('Copilot CLI'));
2241
}));
2242
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.newSessionToSide', async () => {
2243
await copilotcliSessionItemProvider.createCopilotCLITerminal('editorBeside', l10n.t('Copilot CLI'));
2244
}));
2245
disposableStore.add(vscode.commands.registerCommand(OPEN_IN_COPILOT_CLI_COMMAND_ID, async (sourceControlContext?: unknown) => {
2246
const rootUri = getSourceControlRootUri(sourceControlContext);
2247
await copilotcliSessionItemProvider.createCopilotCLITerminal('editor', l10n.t('Copilot CLI'), rootUri?.fsPath);
2248
}));
2249
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.openWorktreeInNewWindow', async (sessionItem?: vscode.ChatSessionItem) => {
2250
if (!sessionItem?.resource) {
2251
return;
2252
}
2253
2254
const id = SessionIdForCLI.parse(sessionItem.resource);
2255
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2256
const folderInfo = await folderRepositoryManager.getFolderRepository(sessionId, undefined, CancellationToken.None);
2257
const folder = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;
2258
if (folder) {
2259
await vscode.commands.executeCommand('vscode.openFolder', folder, { forceNewWindow: true });
2260
}
2261
}));
2262
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.openWorktreeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => {
2263
if (!sessionItem?.resource) {
2264
return;
2265
}
2266
2267
const id = SessionIdForCLI.parse(sessionItem.resource);
2268
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2269
const folderInfo = await folderRepositoryManager.getFolderRepository(sessionId, undefined, CancellationToken.None);
2270
const folder = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;
2271
if (folder) {
2272
vscode.window.createTerminal({ cwd: folder }).show();
2273
}
2274
}));
2275
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.copyWorktreeBranchName', async (sessionItem?: vscode.ChatSessionItem) => {
2276
if (!sessionItem?.resource) {
2277
return;
2278
}
2279
2280
const id = SessionIdForCLI.parse(sessionItem.resource);
2281
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
2282
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2283
if (worktreeProperties?.branchName) {
2284
await vscode.env.clipboard.writeText(worktreeProperties.branchName);
2285
}
2286
}));
2287
async function selectFolder() {
2288
// Open folder picker dialog
2289
const folderUris = await vscode.window.showOpenDialog({
2290
canSelectFiles: false,
2291
canSelectFolders: true,
2292
canSelectMany: false,
2293
openLabel: l10n.t('Open Folder...'),
2294
});
2295
2296
return folderUris && folderUris.length > 0 ? folderUris[0] : undefined;
2297
}
2298
2299
function getSourceControlRootUri(sourceControlContext?: unknown): vscode.Uri | undefined {
2300
if (!sourceControlContext) {
2301
return undefined;
2302
}
2303
2304
if (Array.isArray(sourceControlContext)) {
2305
return getSourceControlRootUri(sourceControlContext[0]);
2306
}
2307
2308
if (isUri(sourceControlContext)) {
2309
return sourceControlContext;
2310
}
2311
2312
if (typeof sourceControlContext !== 'object') {
2313
return undefined;
2314
}
2315
2316
const candidate = sourceControlContext as {
2317
rootUri?: unknown;
2318
sourceControl?: { rootUri?: unknown };
2319
repository?: { rootUri?: unknown };
2320
};
2321
2322
if (isUri(candidate.rootUri)) {
2323
return candidate.rootUri;
2324
}
2325
2326
if (isUri(candidate.sourceControl?.rootUri)) {
2327
return candidate.sourceControl.rootUri;
2328
}
2329
2330
if (isUri(candidate.repository?.rootUri)) {
2331
return candidate.repository.rootUri;
2332
}
2333
2334
return undefined;
2335
}
2336
2337
disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (sessionItemResource?: vscode.Uri) => {
2338
if (!sessionItemResource) {
2339
return;
2340
}
2341
2342
let selectedFolderUri: Uri | undefined = undefined;
2343
const mruItems = await cliFolderMruService.getRecentlyUsedFolders(CancellationToken.None);
2344
2345
if (mruItems.length === 0) {
2346
selectedFolderUri = await selectFolder();
2347
} else {
2348
type RecentFolderQuickPickItem = vscode.QuickPickItem & ({ folderUri: vscode.Uri; openFolder: false } | { folderUri: undefined; openFolder: true });
2349
const items: RecentFolderQuickPickItem[] = mruItems
2350
.map(item => {
2351
const optionItem = item.repository
2352
? toRepositoryOptionItem(item.folder)
2353
: toWorkspaceFolderOptionItem(item.folder, basename(item.folder));
2354
2355
return {
2356
label: optionItem.name,
2357
description: `~/${relative(envService.userHome.fsPath, item.folder.fsPath)}`,
2358
iconPath: optionItem.icon,
2359
folderUri: item.folder,
2360
openFolder: false
2361
};
2362
});
2363
2364
items.unshift({
2365
label: l10n.t('Open Folder...'),
2366
iconPath: new vscode.ThemeIcon('folder-opened'),
2367
folderUri: undefined,
2368
openFolder: true
2369
}, {
2370
kind: vscode.QuickPickItemKind.Separator,
2371
label: '',
2372
folderUri: undefined,
2373
openFolder: true
2374
});
2375
2376
const selectedFolder = new DeferredPromise<Uri | undefined>();
2377
const disposables = new DisposableStore();
2378
const quickPick = disposables.add(vscode.window.createQuickPick<RecentFolderQuickPickItem>());
2379
quickPick.items = items;
2380
quickPick.placeholder = l10n.t('Select a recent folder');
2381
quickPick.matchOnDescription = true;
2382
quickPick.ignoreFocusOut = true;
2383
quickPick.matchOnDetail = true;
2384
quickPick.show();
2385
disposables.add(quickPick.onDidHide(() => {
2386
selectedFolder.complete(undefined);
2387
}));
2388
disposables.add(quickPick.onDidAccept(async () => {
2389
if (quickPick.selectedItems.length === 0 && !quickPick.value) {
2390
selectedFolder.complete(undefined);
2391
quickPick.hide();
2392
} else if (quickPick.selectedItems.length && quickPick.selectedItems[0].folderUri) {
2393
selectedFolder.complete(quickPick.selectedItems[0].folderUri);
2394
quickPick.hide();
2395
} else if (quickPick.selectedItems.length && quickPick.selectedItems[0].openFolder) {
2396
selectedFolder.complete(await selectFolder());
2397
quickPick.hide();
2398
} else if (quickPick.value) {
2399
const fileOrFolder = vscode.Uri.file(quickPick.value);
2400
try {
2401
const stat = await vscode.workspace.fs.stat(fileOrFolder);
2402
let directory: Uri | undefined = undefined;
2403
if (stat.type & vscode.FileType.Directory) {
2404
quickPick.hide();
2405
directory = fileOrFolder;
2406
} else if (stat.type & vscode.FileType.File) {
2407
directory = dirname(fileOrFolder);
2408
}
2409
if (directory) {
2410
// Possible user selected a folder thats inside an existing workspace folder.
2411
selectedFolder.complete(vscode.workspace.getWorkspaceFolder(directory)?.uri || directory);
2412
quickPick.hide();
2413
}
2414
} catch {
2415
// ignore
2416
}
2417
}
2418
}));
2419
selectedFolderUri = await selectedFolder.p;
2420
disposables.dispose();
2421
}
2422
2423
if (!selectedFolderUri) {
2424
return;
2425
}
2426
if (!(await checkPathExists(selectedFolderUri, fileSystemService))) {
2427
const message = l10n.t('The path \'{0}\' does not exist on this computer.', selectedFolderUri.fsPath);
2428
vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message });
2429
return;
2430
}
2431
2432
const sessionId = SessionIdForCLI.parse(sessionItemResource);
2433
contentProvider.trackLastUsedFolderInWelcomeView(selectedFolderUri);
2434
folderRepositoryManager.setNewSessionFolder(sessionId, selectedFolderUri);
2435
2436
// Notify VS Code that the option changed
2437
contentProvider.notifySessionOptionsChange(sessionItemResource, [{
2438
optionId: REPOSITORY_OPTION_ID,
2439
value: selectedFolderUri.fsPath
2440
}]);
2441
2442
// Notify that provider options have changed so the dropdown updates
2443
contentProvider.notifyProviderOptionsChange();
2444
2445
}));
2446
2447
const applyChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2448
const resource = sessionItemOrResource instanceof vscode.Uri
2449
? sessionItemOrResource
2450
: sessionItemOrResource?.resource;
2451
2452
if (!resource) {
2453
return;
2454
}
2455
2456
try {
2457
// Apply changes
2458
const sessionId = SessionIdForCLI.parse(resource);
2459
await copilotCLIWorktreeManagerService.applyWorktreeChanges(sessionId);
2460
2461
// Close the multi-file diff editor if it's open
2462
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2463
const worktreePath = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : undefined;
2464
2465
if (worktreePath) {
2466
// Select the tabs to close
2467
const multiDiffTabToClose = vscode.window.tabGroups.all.flatMap(g => g.tabs)
2468
.filter(({ input }) => input instanceof vscode.TabInputTextMultiDiff && input.textDiffs.some(input =>
2469
extUri.isEqualOrParent(vscode.Uri.file(input.original.fsPath), worktreePath, true) ||
2470
extUri.isEqualOrParent(vscode.Uri.file(input.modified.fsPath), worktreePath, true)));
2471
2472
if (multiDiffTabToClose.length > 0) {
2473
// Close the tabs
2474
await vscode.window.tabGroups.close(multiDiffTabToClose, true);
2475
}
2476
}
2477
2478
// Pick up new git state
2479
copilotcliSessionItemProvider.notifySessionsChange();
2480
} catch (error) {
2481
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 });
2482
}
2483
};
2484
2485
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.applyCopilotCLIAgentSessionChanges', applyChanges));
2486
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.applyCopilotCLIAgentSessionChanges.apply', applyChanges));
2487
2488
const mergeChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri, syncWithRemote: boolean = false) => {
2489
const resource = sessionItemOrResource instanceof vscode.Uri
2490
? sessionItemOrResource
2491
: sessionItemOrResource?.resource;
2492
2493
if (!resource) {
2494
return;
2495
}
2496
2497
let branchName: string | undefined;
2498
let worktreePath: string | undefined;
2499
let baseBranchName: string | undefined;
2500
let baseWorktreePath: string | undefined;
2501
2502
try {
2503
const sessionId = SessionIdForCLI.parse(resource);
2504
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2505
if (!worktreeProperties || worktreeProperties.version !== 2) {
2506
vscode.window.showErrorMessage(l10n.t('Merging changes is only supported for worktree-based sessions.'));
2507
return;
2508
}
2509
2510
branchName = worktreeProperties.branchName;
2511
baseBranchName = worktreeProperties.baseBranchName;
2512
} catch (error) {
2513
logService.error(`Failed to check worktree properties for merge changes: ${error instanceof Error ? error.message : String(error)}`);
2514
return;
2515
}
2516
2517
const contextValueSegments: string[] = [];
2518
contextValueSegments.push(`source branch name: ${branchName}`);
2519
contextValueSegments.push(`source worktree path: ${worktreePath}`);
2520
contextValueSegments.push(`target branch name: ${baseBranchName}`);
2521
contextValueSegments.push(`target worktree path: ${baseWorktreePath}`);
2522
2523
const prompt = syncWithRemote
2524
? `${builtinSlashSCommands.merge} and ${builtinSlashSCommands.sync}`
2525
: builtinSlashSCommands.merge;
2526
2527
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2528
resource,
2529
prompt,
2530
attachedContext: [{
2531
id: 'git-merge-changes',
2532
value: contextValueSegments.join('\n'),
2533
icon: new vscode.ThemeIcon('git-merge'),
2534
fullName: `${branchName} → ${baseBranchName}`,
2535
kind: 'generic'
2536
}]
2537
});
2538
};
2539
2540
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2541
await mergeChanges(sessionItemOrResource);
2542
}));
2543
2544
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.mergeCopilotCLIAgentSessionChanges.mergeAndSync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2545
await mergeChanges(sessionItemOrResource, true);
2546
}));
2547
2548
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.refreshChanges', async (resource?: vscode.Uri) => {
2549
if (!resource) {
2550
return;
2551
}
2552
2553
const sessionId = SessionIdForCLI.parse(resource);
2554
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2555
const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);
2556
2557
if (!worktreeProperties && !workspaceFolder) {
2558
return;
2559
}
2560
2561
if (worktreeProperties) {
2562
// Worktree
2563
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, {
2564
...worktreeProperties,
2565
changes: undefined
2566
});
2567
} else if (workspaceFolder) {
2568
// Workspace
2569
copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId);
2570
}
2571
2572
copilotcliSessionItemProvider.notifySessionsChange();
2573
}));
2574
2575
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.initializeRepository', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2576
const resource = sessionItemOrResource instanceof vscode.Uri
2577
? sessionItemOrResource
2578
: sessionItemOrResource?.resource;
2579
2580
if (!resource) {
2581
return;
2582
}
2583
2584
const sessionId = SessionIdForCLI.parse(resource);
2585
const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);
2586
if (!workspaceFolder) {
2587
return;
2588
}
2589
2590
const repository = await gitService.initRepository(workspaceFolder);
2591
if (!repository) {
2592
return;
2593
}
2594
2595
const repositoryProperties = repository.state.HEAD?.name
2596
? {
2597
repositoryPath: repository.rootUri.fsPath,
2598
branchName: repository.state.HEAD.name
2599
} satisfies RepositoryProperties
2600
: undefined;
2601
2602
await copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repositoryProperties);
2603
copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId);
2604
2605
copilotcliSessionItemProvider.notifySessionsChange();
2606
}));
2607
2608
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commit', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2609
const resource = sessionItemOrResource instanceof vscode.Uri
2610
? sessionItemOrResource
2611
: sessionItemOrResource?.resource;
2612
2613
if (!resource) {
2614
return;
2615
}
2616
2617
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2618
resource,
2619
prompt: builtinSlashSCommands.commit,
2620
});
2621
}));
2622
2623
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commitAndSync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2624
const resource = sessionItemOrResource instanceof vscode.Uri
2625
? sessionItemOrResource
2626
: sessionItemOrResource?.resource;
2627
2628
if (!resource) {
2629
return;
2630
}
2631
2632
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2633
resource,
2634
prompt: `${builtinSlashSCommands.commit} and ${builtinSlashSCommands.sync}`,
2635
});
2636
}));
2637
2638
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.sync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2639
const resource = sessionItemOrResource instanceof vscode.Uri
2640
? sessionItemOrResource
2641
: sessionItemOrResource?.resource;
2642
2643
if (!resource) {
2644
return;
2645
}
2646
2647
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2648
resource,
2649
prompt: builtinSlashSCommands.sync,
2650
});
2651
}));
2652
2653
disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.discardChanges', async (sessionResource: vscode.Uri, ref: string, ...resources: vscode.Uri[]) => {
2654
if (!isUri(sessionResource) || !ref || resources.length === 0 || resources.some(r => !isUri(r))) {
2655
return;
2656
}
2657
2658
const sessionId = SessionIdForCLI.parse(sessionResource);
2659
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2660
const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);
2661
2662
const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder;
2663
const repository = repositoryUri ? await gitService.getRepository(repositoryUri) : undefined;
2664
if (!repository) {
2665
return;
2666
}
2667
2668
const confirmAction = l10n.t('Discard Changes');
2669
const message = resources.length === 1
2670
? l10n.t('Are you sure you want to discard the changes in \'{0}\'? This action cannot be undone.', basename(resources[0]))
2671
: l10n.t('Are you sure you want to discard the changes in these {0} files? This action cannot be undone.', resources.length);
2672
2673
const choice = await vscode.window.showWarningMessage(message, { modal: true }, confirmAction);
2674
if (choice !== confirmAction) {
2675
return;
2676
}
2677
2678
await gitService.restore(repository.rootUri, resources.map(r => r.fsPath), { ref });
2679
}));
2680
2681
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2682
const resource = sessionItemOrResource instanceof vscode.Uri
2683
? sessionItemOrResource
2684
: sessionItemOrResource?.resource;
2685
2686
if (!resource) {
2687
return;
2688
}
2689
2690
try {
2691
const sessionId = SessionIdForCLI.parse(resource);
2692
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2693
if (!worktreeProperties || worktreeProperties.version !== 2) {
2694
vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.'));
2695
return;
2696
}
2697
} catch (error) {
2698
logService.error(`Failed to check worktree properties for createPR: ${error instanceof Error ? error.message : String(error)}`);
2699
return;
2700
}
2701
2702
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2703
resource,
2704
prompt: builtinSlashSCommands.createPr,
2705
});
2706
}));
2707
2708
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2709
const resource = sessionItemOrResource instanceof vscode.Uri
2710
? sessionItemOrResource
2711
: sessionItemOrResource?.resource;
2712
2713
if (!resource) {
2714
return;
2715
}
2716
2717
try {
2718
const sessionId = SessionIdForCLI.parse(resource);
2719
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2720
if (!worktreeProperties || worktreeProperties.version !== 2) {
2721
vscode.window.showErrorMessage(l10n.t('Creating a draft pull request is only supported for worktree-based sessions.'));
2722
return;
2723
}
2724
} catch (error) {
2725
logService.error(`Failed to check worktree properties for createDraftPR: ${error instanceof Error ? error.message : String(error)}`);
2726
return;
2727
}
2728
2729
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2730
resource,
2731
prompt: builtinSlashSCommands.createDraftPr,
2732
});
2733
}));
2734
2735
disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
2736
const resource = sessionItemOrResource instanceof vscode.Uri
2737
? sessionItemOrResource
2738
: sessionItemOrResource?.resource;
2739
2740
if (!resource) {
2741
return;
2742
}
2743
2744
let pullRequestUrl: string | undefined = undefined;
2745
2746
try {
2747
const sessionId = SessionIdForCLI.parse(resource);
2748
const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2749
if (!worktreeProperties || worktreeProperties.version !== 2) {
2750
vscode.window.showErrorMessage(l10n.t('Updating a pull request is only supported for worktree-based sessions.'));
2751
return;
2752
}
2753
2754
pullRequestUrl = worktreeProperties.pullRequestUrl;
2755
} catch (error) {
2756
logService.error(`Failed to check worktree properties for updatePR: ${error instanceof Error ? error.message : String(error)}`);
2757
return;
2758
}
2759
2760
if (!pullRequestUrl) {
2761
vscode.window.showErrorMessage(l10n.t('No pull request URL found for this session.'));
2762
return;
2763
}
2764
2765
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
2766
resource,
2767
prompt: builtinSlashSCommands.updatePr,
2768
attachedContext: [{
2769
id: 'github-pull-request',
2770
fullName: pullRequestUrl,
2771
icon: new vscode.ThemeIcon('git-pull-request'),
2772
value: vscode.Uri.parse(pullRequestUrl),
2773
kind: 'generic'
2774
}]
2775
});
2776
}));
2777
2778
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => {
2779
logService.trace(`[commitToWorktree] Command invoked, args: ${JSON.stringify(args, null, 2)}`);
2780
if (!args?.worktreeUri || !args?.fileUri) {
2781
logService.debug('[commitToWorktree] Missing worktreeUri or fileUri, aborting');
2782
return;
2783
}
2784
2785
const worktreeUri = vscode.Uri.from(args.worktreeUri);
2786
const fileUri = vscode.Uri.from(args.fileUri);
2787
try {
2788
const fileName = basename(fileUri);
2789
await gitService.add(worktreeUri, [fileUri.fsPath]);
2790
logService.debug(`[commitToWorktree] Committing with message: Update customization: ${fileName}`);
2791
await gitService.commit(worktreeUri, l10n.t('Update customization: {0}', fileName), { noVerify: true, signCommit: false });
2792
logService.trace('[commitToWorktree] Commit successful');
2793
2794
// Clear the worktree changes cache so getWorktreeChanges() recomputes
2795
const sessionIds = await copilotcliSessionItemProvider.getAssociatedSessions(worktreeUri);
2796
await Promise.all(sessionIds.map(async sessionId => {
2797
const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
2798
if (props) {
2799
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined });
2800
} else {
2801
logService.error('[commitToWorktree] No worktree properties found for session:', sessionId);
2802
}
2803
}));
2804
2805
logService.trace('[commitToWorktree] Notifying sessions change');
2806
copilotcliSessionItemProvider.notifySessionsChange();
2807
} catch (error) {
2808
const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string };
2809
const normalizedStdout = stdout.toLowerCase();
2810
const normalizedStderr = stderr.toLowerCase();
2811
if (normalizedStdout.includes('nothing to commit') || normalizedStderr.includes('nothing to commit') || gitErrorCode === 'NoLocalChanges' || gitErrorCode === 'NotAGitRepository') {
2812
logService.debug('[commitToWorktree] Nothing to commit or non-applicable repository state, skipping');
2813
return;
2814
}
2815
logService.error('[commitToWorktree] Error:', error);
2816
vscode.window.showErrorMessage(l10n.t('Failed to commit: {0}', error instanceof Error ? error.message : String(error)));
2817
}
2818
}));
2819
2820
disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToRepository', async (args?: { repositoryUri?: vscode.Uri; fileUri?: vscode.Uri }) => {
2821
logService.trace(`[commitToRepository] Command invoked, args: ${JSON.stringify(args, null, 2)}`);
2822
if (!args?.repositoryUri || !args?.fileUri) {
2823
logService.debug('[commitToRepository] Missing repositoryUri or fileUri, aborting');
2824
return;
2825
}
2826
2827
const repositoryUri = vscode.Uri.from(args.repositoryUri);
2828
const fileUri = vscode.Uri.from(args.fileUri);
2829
try {
2830
const fileName = basename(fileUri);
2831
await gitService.add(repositoryUri, [fileUri.fsPath]);
2832
2833
const message = l10n.t('Update customization: {0}', fileName);
2834
logService.debug(`[commitToRepository] Committing with message: ${message}`);
2835
await gitService.commit(repositoryUri, message, { noVerify: true, signCommit: false });
2836
logService.trace('[commitToRepository] Commit successful');
2837
} catch (error) {
2838
const stderr = (error as { stderr?: string })?.stderr ?? '';
2839
const stdout = (error as { stdout?: string })?.stdout ?? '';
2840
const gitErrorCode = (error as { gitErrorCode?: string })?.gitErrorCode;
2841
2842
// Benign: nothing was staged or no local changes to commit
2843
if (stderr.includes('nothing to commit') || stdout.includes('nothing to commit') || gitErrorCode === 'NoLocalChanges') {
2844
logService.debug('[commitToRepository] Nothing to commit, skipping');
2845
return;
2846
}
2847
2848
// Benign: repository URI doesn't point to a git repo
2849
if (gitErrorCode === 'NotAGitRepository') {
2850
logService.debug('[commitToRepository] Not a git repository, skipping');
2851
return;
2852
}
2853
2854
logService.error('[commitToRepository] Error:', error);
2855
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."));
2856
}
2857
}));
2858
2859
return disposableStore;
2860
}
2861
2862
async function getModelFromPromptFile(models: readonly string[], copilotCLIModels: ICopilotCLIModels): Promise<string | undefined> {
2863
for (const model of models) {
2864
let modelId = await copilotCLIModels.resolveModel(model);
2865
if (modelId) {
2866
return modelId;
2867
}
2868
// Sometimes the models can contain ` (Copilot)` suffix, try stripping that and resolving again.
2869
if (!model.includes('(')) {
2870
continue;
2871
}
2872
modelId = await copilotCLIModels.resolveModel(model.substring(0, model.indexOf('(')).trim());
2873
if (modelId) {
2874
return modelId;
2875
}
2876
}
2877
return undefined;
2878
}
2879
2880
2881
function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] {
2882
return mruItems.map((item) => {
2883
if (item.repository) {
2884
return toRepositoryOptionItem(item.folder);
2885
} else {
2886
return toWorkspaceFolderOptionItem(item.folder, basename(item.folder));
2887
}
2888
});
2889
2890
}
2891
2892
2893
/**
2894
* Check if a path exists and is a directory.
2895
*/
2896
async function checkPathExists(filePath: vscode.Uri, fileSystemService: IFileSystemService): Promise<boolean> {
2897
try {
2898
const stat = await fileSystemService.stat(filePath);
2899
return stat.type === vscode.FileType.Directory;
2900
} catch {
2901
return false;
2902
}
2903
}
2904
2905
function isUnknownEventTypeError(error: unknown): boolean {
2906
const message = error instanceof Error ? error.message : String(error);
2907
return /Unknown event type:/i.test(message);
2908
}
2909
2910
/**
2911
* Queries the GitHub API to find a pull request whose head branch matches the
2912
* given worktree branch. This covers cases where the MCP tool failed to report
2913
* a PR URL, or the user created the PR externally (e.g., via github.com).
2914
*/
2915
async function detectPullRequestFromGitHubAPI(
2916
branchName: string,
2917
repositoryPath: string,
2918
gitService: IGitService,
2919
octoKitService: IOctoKitService,
2920
logService: ILogService,
2921
): Promise<{ url: string; state: string } | undefined> {
2922
const repoContext = await gitService.getRepository(URI.file(repositoryPath));
2923
if (!repoContext) {
2924
logService.debug(`[detectPullRequestFromGitHubAPI] No git repository found for path: ${repositoryPath}`);
2925
return undefined;
2926
}
2927
2928
const repoInfo = getGitHubRepoInfoFromContext(repoContext);
2929
if (!repoInfo) {
2930
logService.debug(`[detectPullRequestFromGitHubAPI] Could not extract GitHub repo info from repository at: ${repositoryPath}`);
2931
return undefined;
2932
}
2933
2934
logService.debug(`[detectPullRequestFromGitHubAPI] Querying GitHub API for PR on ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);
2935
2936
const pr = await octoKitService.findPullRequestByHeadBranch(
2937
repoInfo.id.org,
2938
repoInfo.id.repo,
2939
branchName,
2940
{},
2941
);
2942
2943
if (pr?.url) {
2944
const prState = derivePullRequestState(pr);
2945
logService.trace(`[detectPullRequestFromGitHubAPI] Detected pull request via GitHub API: ${pr.url} ${prState}`);
2946
return { url: pr.url, state: prState };
2947
}
2948
2949
logService.debug(`[detectPullRequestFromGitHubAPI] No PR found for ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);
2950
return undefined;
2951
}
2952
2953