Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';6import * as l10n from '@vscode/l10n';7import * as vscode from 'vscode';8import { ChatExtendedRequestHandler, ChatRequestTurn2, ChatSessionProviderOptionItem, Uri } from 'vscode';9import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService';10import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';11import { INativeEnvService } from '../../../platform/env/common/envService';12import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';13import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';14import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';15import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService';16import { toGitUri } from '../../../platform/git/common/utils';17import { derivePullRequestState } from '../../../platform/github/common/githubAPI';18import { IOctoKitService } from '../../../platform/github/common/githubService';19import { ILogService } from '../../../platform/log/common/logService';20import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';21import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';22import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';23import { isUri } from '../../../util/common/types';24import { DeferredPromise, disposableTimeout, IntervalTimer, raceCancellation, SequencerByKey } from '../../../util/vs/base/common/async';25import { CancellationToken } from '../../../util/vs/base/common/cancellation';26import { isCancellationError } from '../../../util/vs/base/common/errors';27import { Emitter, Event } from '../../../util/vs/base/common/event';28import { Disposable, DisposableStore, IDisposable, IReference } from '../../../util/vs/base/common/lifecycle';29import { relative } from '../../../util/vs/base/common/path';30import { basename, dirname, extUri, isEqual } from '../../../util/vs/base/common/resources';31import { StopWatch } from '../../../util/vs/base/common/stopwatch';32import { URI } from '../../../util/vs/base/common/uri';33import { EXTENSION_ID } from '../../common/constants';34import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection';35import { GitBranchNameGenerator } from '../../prompt/node/gitBranch';36import { IToolsService } from '../../tools/common/toolsService';37import { IChatSessionMetadataStore, RepositoryProperties, StoredModeInstructions } from '../common/chatSessionMetadataStore';38import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';39import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';40import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';41import { FolderRepositoryInfo, FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';42import { isUntitledSessionId } from '../common/utils';43import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';44import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';45import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';46import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';47import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, formatModelDetails, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli';48import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver';49import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession';50import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';51import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler';52import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';53import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions';54import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';55import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';56import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences';57import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';5859const REPOSITORY_OPTION_ID = 'repository';60const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';6162const _sessionWorktreeIsolationCache = new Map<string, boolean>();63const BRANCH_OPTION_ID = 'branch';64const ISOLATION_OPTION_ID = 'isolation';65const PARENT_SESSION_OPTION_ID = 'parentSessionId';66const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption';67const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository';68const OPEN_IN_COPILOT_CLI_COMMAND_ID = 'github.copilot.cli.openInCopilotCLI';69const MAX_MRU_ENTRIES = 10;70const CHECK_FOR_STEERING_DELAY = 100; // ms7172// When we start new sessions, we don't have the real session id, we have a temporary untitled id.73// We also need this when we open a session and later run it.74// 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.75const _sessionBranch: Map<string, string | undefined> = new Map();76const _sessionIsolation: Map<string, IsolationMode | undefined> = new Map();7778const _invalidCopilotCLISessionIdsWithErrorMessage = new Map<string, string>();7980namespace SessionIdForCLI {81export function getResource(sessionId: string): vscode.Uri {82return vscode.Uri.from({83scheme: 'copilotcli', path: `/${sessionId}`,84});85}8687export function parse(resource: vscode.Uri): string {88return resource.path.slice(1);89}9091export function isCLIResource(resource: vscode.Uri): boolean {92return resource.scheme === 'copilotcli';93}94}9596/**97* Escape XML special characters98*/99function escapeXml(text: string): string {100return text101.replace(/&/g, '&')102.replace(/</g, '<')103.replace(/>/g, '>')104.replace(/"/g, '"')105.replace(/'/g, ''');106}107108function getIssueRuntimeInfo(): { readonly platform: string; readonly vscodeInfo: string; readonly extensionVersion: string } {109const extensionVersion = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON?.version;110111return {112platform: `${process.platform}-${process.arch}`,113vscodeInfo: `${vscode.env.appName} ${vscode.version}`,114extensionVersion: extensionVersion ?? 'unknown'115};116}117118function getSessionLoadFailureIssueInfo(invalidSessionMessage: string): { readonly issueBody: string; readonly issueUrl: string } {119const runtimeInfo = getIssueRuntimeInfo();120const issueTitle = '[Copilot CLI] Failed to load chat session';121const 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\`\`\``;122const issueUrl = `https://github.com/microsoft/vscode/issues/new?title=${encodeURIComponent(issueTitle)}&body=${encodeURIComponent(issueBody)}`;123124return { issueBody, issueUrl };125}126127/**128* Resolves candidate session directories for a CLI terminal, ordered by129* terminal affinity.130*131* Sessions whose owning terminal matches `terminal` are returned first so the132* link provider's file-existence probing hits the correct session-state dir133* before unrelated ones. Unrelated sessions are still included at the tail134* because a new session may not have registered its terminal yet (session IDs135* arrive later via MCP?).136*/137export async function resolveSessionDirsForTerminal(138sessionTracker: ICopilotCLISessionTracker,139terminal: vscode.Terminal,140): Promise<Uri[]> {141const activeIds = sessionTracker.getSessionIds();142const matching: Uri[] = [];143const rest: Uri[] = [];144for (const id of activeIds) {145const sessionTerminal = await sessionTracker.getTerminal(id);146const dir = Uri.file(getCopilotCLISessionDir(id));147if (sessionTerminal === terminal) {148matching.push(dir);149} else {150rest.push(dir);151}152}153return [...matching, ...rest];154}155156export class CopilotCLIChatSessionItemProvider extends Disposable implements vscode.ChatSessionItemProvider, ICopilotCLIChatSessionItemProvider {157// When we start an untitled CLI session, the id of the session is `untitled:xyz`158// As soon as we create a CLI session we have the real session id, lets say `cli-1234`159// Once the session completes, this untitled session `untitled:xyz` will get swapped with the real session id `cli-1234`160// 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.161// There's an issue in core (about holding onto ref of the Chat Model).162// As a temporary solution, return the same untitled session id back to core until the session is completed.163public readonly untitledSessionIdMapping = new Map<string, string>();164/**165* Until the untitled session is properly swappped with the new session, we should keep track of this mapping.166* When VS Code asks for the session, always return the old untitled session Uri.167*/168public readonly sdkToUntitledUriMapping = new Map<string, Uri>();169private readonly _onDidChangeChatSessionItems = this._register(new Emitter<void>());170public readonly onDidChangeChatSessionItems: Event<void> = this._onDidChangeChatSessionItems.event;171172private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());173public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;174/**175* Session ids that were targeted by an explicit `refreshSession(...)` call and have not yet been176* re-provided. The next `provideChatSessionItems` pass eagerly includes `changes` for these177* sessions so the visible row reflects the latest diff info — VS Code uses the items returned178* from `provideChatSessionItems` as source of truth and does not re-invoke `resolveChatSessionItem`179* for already-visible rows. The set is cleared after each `provideChatSessionItems` call.180*/181private readonly pendingChangeIncludeIds = new Set<string>();182183public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise<vscode.ChatSessionItem | undefined>;184185constructor(186@ICopilotCLISessionService private readonly copilotcliSessionService: ICopilotCLISessionService,187@ICopilotCLISessionTracker private readonly sessionTracker: ICopilotCLISessionTracker,188@ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration,189@IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore,190@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,191@IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService,192@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,193@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,194@IGitService private readonly gitService: IGitService,195@IOctoKitService private readonly octoKitService: IOctoKitService,196@ILogService private readonly logService: ILogService,197@IConfigurationService private readonly configurationService: IConfigurationService,198) {199super();200this._register(this.terminalIntegration);201this._register(configurationService.onDidChangeConfiguration(e => {202if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) {203this._onDidChangeChatSessionItems.fire();204}205}));206207if (configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {208this.resolveChatSessionItem = async (item: vscode.ChatSessionItem, token: vscode.CancellationToken): Promise<vscode.ChatSessionItem | undefined> => {209const sessionId = SessionIdForCLI.parse(item.resource);210const session = await this.copilotcliSessionService.getSessionItem(sessionId, token);211if (!session || token.isCancellationRequested) {212return undefined;213}214return this.toChatSessionItem(session, { includeChanges: true }, token);215};216}217218// Resolve session dirs for terminal links. See resolveSessionDirsForTerminal.219this.terminalIntegration.setSessionDirResolver(terminal =>220resolveSessionDirsForTerminal(this.sessionTracker, terminal)221);222223this._register(this.copilotcliSessionService.onDidChangeSessions(() => {224this.notifySessionsChange();225}));226}227228public getAssociatedSessions(folder: Uri): string[] {229return this.chatSessionMetadataStore.getSessionIdsForFolder(folder);230}231232/**233* We should remove this or move this to CopilotCLISessionService234*/235public isNewSession(session: string) {236return isUntitledSessionId(session);237}238239public notifySessionsChange(): void {240// Refresh the bulk metadata cache from disk so cross-process writes241// (e.g. another VS Code window editing the same session) become visible242// before consumers re-read items.243this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });244this._onDidChangeChatSessionItems.fire();245}246247public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void> {248await this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });249if (refreshOptions.reason === 'update') {250// Mark the targeted sessions so the next `provideChatSessionItems` pass includes251// fresh `changes` for them (push path equivalent — see `pendingChangeIncludeIds`).252if ('sessionIds' in refreshOptions) {253for (const id of refreshOptions.sessionIds) {254this.pendingChangeIncludeIds.add(id);255}256} else {257this.pendingChangeIncludeIds.add(refreshOptions.sessionId);258}259}260this._onDidChangeChatSessionItems.fire();261}262263public swap(original: vscode.ChatSessionItem, modified: vscode.ChatSessionItem): void {264this._onDidCommitChatSessionItem.fire({ original, modified });265}266267public async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {268const stopwatch = new StopWatch();269const sessions = await this.copilotcliSessionService.getAllSessions(token);270// Drain the pending set: sessions that were explicitly refreshed get `changes` populated271// eagerly so the visible row reflects the latest diff info on this re-provide pass.272const pendingIds = new Set(this.pendingChangeIncludeIds);273this.pendingChangeIncludeIds.clear();274const diskSessions = await Promise.all(sessions.map(async session => this.toChatSessionItem(275session,276pendingIds.has(session.id) ? { includeChanges: true } : undefined,277token,278)));279280const count = diskSessions.length;281void this.commandExecutionService.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0);282this.logService.info(`[CopilotCLIChatSessionContentProvider] listSessions took ${stopwatch.elapsed()}ms`);283return diskSessions;284}285286private shouldShowBadge(): boolean {287const repositories = this.gitService.repositories288.filter(repository => repository.kind !== 'worktree');289290return vscode.workspace.workspaceFolders === undefined || // empty window291vscode.workspace.isAgentSessionsWorkspace || // agent sessions workspace292repositories.length > 1; // multiple repositories293}294295public async toChatSessionItem(session: ICopilotCLISessionItem, options?: { readonly includeChanges?: boolean }, token: vscode.CancellationToken = CancellationToken.None): Promise<vscode.ChatSessionItem> {296const resource = this.sdkToUntitledUriMapping.get(session.id) ?? SessionIdForCLI.getResource(this.untitledSessionIdMapping.get(session.id) ?? session.id);297let worktreeProperties = await raceCancellation(this.worktreeManager.getWorktreeProperties(session.id), token);298const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath)299: session.workingDirectory;300301const label = session.label;302303// Badge304let badge: vscode.MarkdownString | undefined;305if (this.shouldShowBadge() && !token.isCancellationRequested) {306if (worktreeProperties?.repositoryPath) {307// Worktree308const repositoryPathUri = vscode.Uri.file(worktreeProperties.repositoryPath);309const isTrusted = await vscode.workspace.isResourceTrusted(repositoryPathUri);310const badgeIcon = isTrusted ? '$(repo)' : '$(workspace-untrusted)';311312badge = new vscode.MarkdownString(`${badgeIcon} ${basename(repositoryPathUri)}`);313badge.supportThemeIcons = true;314} else if (workingDirectory) {315// Workspace316const isTrusted = await vscode.workspace.isResourceTrusted(workingDirectory);317const badgeIcon = isTrusted ? '$(folder)' : '$(workspace-untrusted)';318319badge = new vscode.MarkdownString(`${badgeIcon} ${basename(workingDirectory)}`);320badge.supportThemeIcons = true;321}322}323324// Statistics (only returned for trusted workspace/worktree folders).325// `getWorktreeChanges`/`getWorkspaceChanges` shell out to `git diff` and dominate the cost326// of building an item — defer to `resolveChatSessionItem` for visible items.327// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the328// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.329// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.330let changes: vscode.ChatSessionChangedFile[] | undefined;331if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) {332changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);333// We need to get an updated version of worktree properties here because when the334// changes are being computed, the worktree properties are also updated with the335// repository state which we are passing along through the metadata336worktreeProperties = await raceCancellation(this.worktreeManager.getWorktreeProperties(session.id), token);337}338339// Status340const status = session.status ?? vscode.ChatSessionStatus.Completed;341342// Metadata343let metadata: { readonly [key: string]: unknown };344const sessionParentId = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token);345346if (worktreeProperties) {347// Worktree348metadata = {349sessionParentId,350autoCommit: worktreeProperties.autoCommit !== false,351baseCommit: worktreeProperties?.baseCommit,352baseBranchName: worktreeProperties.version === 2353? worktreeProperties.baseBranchName354: undefined,355baseBranchProtected: worktreeProperties.version === 2356? worktreeProperties.baseBranchProtected === true357: undefined,358branchName: worktreeProperties?.branchName,359upstreamBranchName: worktreeProperties.version === 2360? worktreeProperties.upstreamBranchName361: undefined,362isolationMode: IsolationMode.Worktree,363repositoryPath: worktreeProperties?.repositoryPath,364worktreePath: worktreeProperties?.worktreePath,365pullRequestUrl: worktreeProperties.version === 2366? worktreeProperties.pullRequestUrl367: undefined,368pullRequestState: worktreeProperties.version === 2369? worktreeProperties.pullRequestState370: undefined,371firstCheckpointRef: worktreeProperties.version === 2372? worktreeProperties.firstCheckpointRef373: undefined,374baseCheckpointRef: worktreeProperties.version === 2375? worktreeProperties.baseCheckpointRef376: undefined,377lastCheckpointRef: worktreeProperties.version === 2378? worktreeProperties.lastCheckpointRef379: undefined,380hasGitHubRemote: worktreeProperties.version === 2381? worktreeProperties.hasGitHubRemote382: undefined,383incomingChanges: worktreeProperties.version === 2384? worktreeProperties.incomingChanges385: undefined,386outgoingChanges: worktreeProperties.version === 2387? worktreeProperties.outgoingChanges388: undefined,389uncommittedChanges: worktreeProperties.version === 2390? worktreeProperties.uncommittedChanges391: undefined392} satisfies { readonly [key: string]: unknown };393} else {394// Workspace395const sessionRequestDetails = await raceCancellation(this.chatSessionMetadataStore.getRequestDetails(session.id), token) ?? [];396const repositoryProperties = await raceCancellation(this.chatSessionMetadataStore.getRepositoryProperties(session.id), token);397398let lastCheckpointRef: string | undefined;399for (let i = sessionRequestDetails.length - 1; i >= 0; i--) {400const checkpointRef = sessionRequestDetails[i]?.checkpointRef;401if (checkpointRef !== undefined) {402lastCheckpointRef = checkpointRef;403break;404}405}406407const firstCheckpointRef = lastCheckpointRef408? `${lastCheckpointRef.slice(0, lastCheckpointRef.lastIndexOf('/'))}/0`409: undefined;410411metadata = {412sessionParentId,413isolationMode: IsolationMode.Workspace,414repositoryPath: repositoryProperties?.repositoryPath,415branchName: repositoryProperties?.branchName,416baseBranchName: repositoryProperties?.baseBranchName,417upstreamBranchName: repositoryProperties?.upstreamBranchName,418workingDirectoryPath: workingDirectory?.fsPath,419hasGitHubRemote: repositoryProperties?.hasGitHubRemote,420incomingChanges: repositoryProperties?.incomingChanges,421outgoingChanges: repositoryProperties?.outgoingChanges,422uncommittedChanges: repositoryProperties?.uncommittedChanges,423firstCheckpointRef,424lastCheckpointRef425} satisfies { readonly [key: string]: unknown };426}427428return {429resource,430label,431badge,432timing: session.timing,433changes,434status,435metadata,436} satisfies vscode.ChatSessionItem;437}438439private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {440if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {441return true;442}443const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([444this.worktreeManager.hasCachedChanges(sessionId),445this.workspaceFolderService.hasCachedChanges(sessionId)446]);447return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;448}449450451private async buildChanges(452sessionId: string,453worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>,454workingDirectory: vscode.Uri | undefined,455token: vscode.CancellationToken456): Promise<vscode.ChatSessionChangedFile[]> {457const changes: vscode.ChatSessionChangedFile[] = [];458if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) {459changes.push(...(await raceCancellation(this.worktreeManager.getWorktreeChanges(sessionId), token) ?? []));460} else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) {461const workspaceChanges = await raceCancellation(this.workspaceFolderService.getWorkspaceChanges(sessionId), token) ?? [];462const repositoryProperties = await raceCancellation(this.chatSessionMetadataStore.getRepositoryProperties(sessionId), token);463464changes.push(...workspaceChanges.map(change => {465const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD';466467return new vscode.ChatSessionChangedFile(468vscode.Uri.file(change.filePath),469change.originalFilePath470? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef)471: undefined,472change.modifiedFilePath473? vscode.Uri.file(change.modifiedFilePath)474: undefined,475change.statistics.additions,476change.statistics.deletions);477}));478}479return changes;480}481482/**483* Detects a pull request for a session when the user opens it.484* If a PR is found, persists the URL and notifies the UI.485*/486public async detectPullRequestOnSessionOpen(sessionId: string): Promise<void> {487try {488const worktreeProperties = await this.worktreeManager.getWorktreeProperties(sessionId);489if (worktreeProperties?.version !== 2490|| worktreeProperties.pullRequestState === 'merged'491|| !worktreeProperties.branchName492|| !worktreeProperties.repositoryPath) {493this.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}`);494return;495}496497this.logService.debug(`[CopilotCLIChatSessionItemProvider] Detecting PR on session open for ${sessionId}, branch=${worktreeProperties.branchName}, existingPrUrl=${worktreeProperties.pullRequestUrl ?? 'none'}`);498499const prResult = await detectPullRequestFromGitHubAPI(500worktreeProperties.branchName,501worktreeProperties.repositoryPath,502this.gitService,503this.octoKitService,504this.logService,505);506507if (prResult) {508const currentProperties = await this.worktreeManager.getWorktreeProperties(sessionId);509if (currentProperties?.version === 2510&& (currentProperties.pullRequestUrl !== prResult.url || currentProperties.pullRequestState !== prResult.state)) {511this.logService.debug(`[CopilotCLIChatSessionItemProvider] Updating PR metadata for ${sessionId}: url=${prResult.url}, state=${prResult.state} (was url=${currentProperties.pullRequestUrl ?? 'none'}, state=${currentProperties.pullRequestState ?? 'none'})`);512await this.worktreeManager.setWorktreeProperties(sessionId, {513...currentProperties,514pullRequestUrl: prResult.url,515pullRequestState: prResult.state,516changes: undefined,517});518this.notifySessionsChange();519} else {520this.logService.debug(`[CopilotCLIChatSessionItemProvider] PR metadata unchanged for ${sessionId}, skipping update`);521}522} else {523this.logService.debug(`[CopilotCLIChatSessionItemProvider] No PR found via GitHub API for ${sessionId}`);524}525} catch (error) {526this.logService.trace(`[CopilotCLIChatSessionItemProvider] Failed to detect pull request on session open for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);527}528}529public async createCopilotCLITerminal(location: TerminalOpenLocation = 'editor', name?: string, cwd?: string): Promise<void> {530// TODO@rebornix should be set by CLI531const terminalName = name || process.env.COPILOTCLI_TERMINAL_TITLE || l10n.t('Copilot CLI');532await this.terminalIntegration.openTerminal(terminalName, [], cwd, location);533}534535public async resumeCopilotCLISessionInTerminal(sessionItem: vscode.ChatSessionItem): Promise<void> {536const id = SessionIdForCLI.parse(sessionItem.resource);537const existingTerminal = await this.sessionTracker.getTerminal(id);538if (existingTerminal) {539existingTerminal.show();540return;541}542543const terminalName = sessionItem.label || id;544const cliArgs = ['--resume', id];545const token = new vscode.CancellationTokenSource();546try {547const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token.token);548const cwd = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;549const terminal = await this.terminalIntegration.openTerminal(terminalName, cliArgs, cwd?.fsPath);550if (terminal) {551this.sessionTracker.setSessionTerminal(id, terminal);552this.terminalIntegration.setTerminalSessionDir(terminal, Uri.file(getCopilotCLISessionDir(id)));553}554} finally {555token.dispose();556}557}558}559560function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean {561return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport);562}563564function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean {565return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption);566}567568function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean {569return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled);570}571572export class CopilotCLIChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider {573private readonly _onDidChangeChatSessionOptions = this._register(new Emitter<vscode.ChatSessionOptionChangeEvent>());574readonly onDidChangeChatSessionOptions = this._onDidChangeChatSessionOptions.event;575private readonly _onDidChangeChatSessionProviderOptions = this._register(new Emitter<void>());576readonly onDidChangeChatSessionProviderOptions = this._onDidChangeChatSessionProviderOptions.event;577578private _currentSessionId: string | undefined;579private _selectedRepoForBranches: { repoUri: URI; headBranchName: string | undefined } | undefined;580private _displayedOptionIds = new Set<string>();581private readonly _activeSessionsById = new Map<string, ICopilotCLISession>();582/**583* ID of the last used folder in an untitled workspace (for defaulting selection).584*/585private _lastUsedFolderIdInUntitledWorkspace: string | undefined;586constructor(587private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider,588@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,589@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,590@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,591@IWorkspaceService private readonly workspaceService: IWorkspaceService,592@IFileSystemService private readonly fileSystem: IFileSystemService,593@IGitService private readonly gitService: IGitService,594@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,595@IConfigurationService private readonly configurationService: IConfigurationService,596@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,597@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,598@ILogService private readonly logService: ILogService,599@IChatFolderMruService private readonly folderMruService: IChatFolderMruService,600) {601super();602const originalRepos = this.getRepositoryOptionItems().length;603this._register(this.gitService.onDidFinishInitialization(() => {604if (originalRepos !== this.getRepositoryOptionItems().length) {605this._onDidChangeChatSessionProviderOptions.fire();606}607}));608this._register(this.gitService.onDidOpenRepository(() => {609if (originalRepos !== this.getRepositoryOptionItems().length) {610this._onDidChangeChatSessionProviderOptions.fire();611}612}));613this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => {614this._onDidChangeChatSessionProviderOptions.fire();615}));616this._register(this.copilotCLIAgents.onDidChangeAgents(() => {617this._onDidChangeChatSessionProviderOptions.fire();618}));619}620621public notifySessionOptionsChange(resource: vscode.Uri, updates: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }>): void {622this._onDidChangeChatSessionOptions.fire({ resource, updates });623}624625public notifyProviderOptionsChange(): void {626this._onDidChangeChatSessionProviderOptions.fire();627}628629private async getDefaultUntitledSessionRepositoryOption(copilotcliSessionId: string | undefined, token: vscode.CancellationToken) {630const repositories = this.isUntitledWorkspace() ? folderMRUToChatProviderOptions(await this.folderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems();631// Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population)632const folderInfo = copilotcliSessionId ? await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token) : undefined;633const uri = folderInfo?.repository ?? folderInfo?.folder;634if (uri) {635return uri;636} else if (repositories.length) {637// No folder selected yet for this untitled session - use MRU or first available638const lastUsedFolderId = this._lastUsedFolderIdInUntitledWorkspace;639const firstRepo = (lastUsedFolderId && repositories.find(repo => repo.id === lastUsedFolderId)?.id) ?? repositories[0].id;640return Uri.file(firstRepo);641}642return undefined;643}644645async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {646const stopwatch = new StopWatch();647try {648const copilotcliSessionId = SessionIdForCLI.parse(resource);649const isUntitled = this.sessionItemProvider.isNewSession(copilotcliSessionId);650if (isUntitled) {651return await this.provideChatSessionContentForUntitledSession(resource, token);652} else {653return await this.provideChatSessionContentForExistingSession(resource, token);654}655} finally {656this.logService.info(`[CopilotCLIChatSessionContentProvider] provideChatSessionContent for ${resource.toString()} took ${stopwatch.elapsed()}ms`);657}658}659660public trackLastUsedFolderInWelcomeView(folderUri: vscode.Uri): void {661// Update MRU tracking for untitled workspaces662if (isWelcomeView(this.workspaceService)) {663this._lastUsedFolderIdInUntitledWorkspace = folderUri.fsPath;664}665}666667async provideChatSessionContentForUntitledSession(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {668const copilotcliSessionId = SessionIdForCLI.parse(resource);669this._currentSessionId = copilotcliSessionId;670const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);671const isUntitled = this.sessionItemProvider.isNewSession(copilotcliSessionId);672const [history, title] = await Promise.all([673isUntitled ? Promise.resolve([]) : this.getSessionHistory(copilotcliSessionId, folderRepo, token),674this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId),675]);676677const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};678679// Use FolderRepositoryManager to get folder/repository info (no trust check needed for UI population)680const defaultRepo = await this.getDefaultUntitledSessionRepositoryOption(copilotcliSessionId, token);681if (defaultRepo) {682// Determine upfront whether the default repository/folder is trusted. We need to do683// this since the user should not be presented with a resource trust dialog in case the684// default repository/folder is not trusted.685const defaultRepoIsTrusted = await vscode.workspace.isResourceTrusted(defaultRepo);686687if (defaultRepoIsTrusted) {688options[REPOSITORY_OPTION_ID] = defaultRepo.fsPath;689// Use the manager to track the selection for untitled sessions690this.trackLastUsedFolderInWelcomeView(defaultRepo);691this.folderRepositoryManager.setNewSessionFolder(copilotcliSessionId, defaultRepo);692693// Check if the default folder is a git repo so the branch dropdown appears immediately694const repoInfo = await this.folderRepositoryManager.getRepositoryInfo(defaultRepo, token);695if (repoInfo.repository) {696this._selectedRepoForBranches = { repoUri: repoInfo.repository, headBranchName: repoInfo.headBranchName };697} else {698this._selectedRepoForBranches = undefined;699}700if (repoInfo.repository && isIsolationOptionFeatureEnabled(this.configurationService)) {701if (!_sessionIsolation.has(copilotcliSessionId)) {702const lastUsed = this.context.globalState.get<IsolationMode>(LAST_USED_ISOLATION_OPTION_KEY, IsolationMode.Workspace);703_sessionIsolation.set(copilotcliSessionId, lastUsed);704}705const isolationMode = _sessionIsolation.get(copilotcliSessionId)!;706options[ISOLATION_OPTION_ID] = {707id: isolationMode,708name: isolationMode === IsolationMode.Worktree ? l10n.t('Worktree') : l10n.t('Workspace'),709icon: new vscode.ThemeIcon(isolationMode === IsolationMode.Worktree ? 'worktree' : 'folder')710};711}712const shouldShowBranch = !isIsolationOptionFeatureEnabled(this.configurationService) || _sessionIsolation.get(copilotcliSessionId) === IsolationMode.Worktree;713const branchItems = await this.getBranchOptionItems();714if (branchItems.length > 0 && shouldShowBranch) {715_sessionBranch.set(copilotcliSessionId, branchItems[0].id);716options[BRANCH_OPTION_ID] = {717id: branchItems[0].id,718name: branchItems[0].name,719icon: new vscode.ThemeIcon('git-branch')720};721}722} else {723options[REPOSITORY_OPTION_ID] = '';724}725726this.notifyProviderOptionsChange();727}728729return {730title,731history,732activeResponseCallback: undefined,733requestHandler: undefined,734options: options735};736}737738async provideChatSessionContentForExistingSession(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {739const copilotcliSessionId = SessionIdForCLI.parse(resource);740this._currentSessionId = copilotcliSessionId;741742// Fire-and-forget: detect PR when the user opens a session743void this.sessionItemProvider.detectPullRequestOnSessionOpen(copilotcliSessionId);744745const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);746const [history, title, folderInfo, worktreeProperties] = await Promise.all([747this.getSessionHistory(copilotcliSessionId, folderRepo, token),748this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId),749this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token),750this.copilotCLIWorktreeManagerService.getWorktreeProperties(copilotcliSessionId)751]);752753const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};754if (folderInfo.repository) {755options[REPOSITORY_OPTION_ID] = {756...toRepositoryOptionItem(folderInfo.repository),757locked: true758};759} else if (folderInfo.folder) {760const folderName = this.workspaceService.getWorkspaceFolderName(folderInfo.folder) || basename(folderInfo.folder);761options[REPOSITORY_OPTION_ID] = {762...toWorkspaceFolderOptionItem(folderInfo.folder, folderName),763locked: true764};765} else {766// Existing session with no folder info - show unknown767let folderName = l10n.t('Unknown');768if (this.workspaceService.getWorkspaceFolders().length === 1) {769folderName = this.workspaceService.getWorkspaceFolderName(this.workspaceService.getWorkspaceFolders()[0]) || folderName;770}771options[REPOSITORY_OPTION_ID] = {772id: '',773name: folderName,774icon: new vscode.ThemeIcon('folder'),775locked: true776};777}778if (worktreeProperties?.repositoryPath) {779const branchName = worktreeProperties.branchName;780const repoUri = vscode.Uri.file(worktreeProperties.repositoryPath);781this._selectedRepoForBranches = { repoUri, headBranchName: branchName };782783options[BRANCH_OPTION_ID] = {784id: branchName,785name: branchName,786icon: new vscode.ThemeIcon('git-branch'),787locked: true788};789}790if (isIsolationOptionFeatureEnabled(this.configurationService)) {791const isWorktree = !!worktreeProperties;792options[ISOLATION_OPTION_ID] = {793id: isWorktree ? IsolationMode.Worktree : IsolationMode.Workspace,794name: isWorktree ? l10n.t('Worktree') : l10n.t('Workspace'),795icon: new vscode.ThemeIcon(isWorktree ? 'worktree' : 'folder'),796locked: true797};798}799800// Ensure the branch option group is shown when we have a branch value but it's not displayed.801if (options[BRANCH_OPTION_ID] && !this._displayedOptionIds.has(BRANCH_OPTION_ID)) {802this.notifyProviderOptionsChange();803}804805if (this.configurationService.getConfig(ConfigKey.Advanced.CLIForkSessionsEnabled)) {806return {807title,808history,809activeResponseCallback: undefined,810requestHandler: undefined,811options: options,812forkHandler: async (sessionResource, requestTurn, token) => {813const sessionId = SessionIdForCLI.parse(sessionResource);814return this.forkSession(sessionId, requestTurn?.id, token);815},816};817} else {818return {819title,820history,821activeResponseCallback: undefined,822requestHandler: undefined,823options: options,824};825}826}827828private async forkSession(sessionId: string, requestId: string | undefined, token: CancellationToken): Promise<vscode.ChatSessionItem> {829const folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, undefined, token);830const forkedSessionId = await this.sessionService.forkSession({ sessionId, requestId, workspace: folderInfo }, token);831832const items = await this.sessionItemProvider.provideChatSessionItems(token);833const forkedSessionUri = SessionIdForCLI.getResource(forkedSessionId);834const item = items.find(i => isEqual(i.resource, forkedSessionUri));835if (!item) {836throw new Error(`Failed to find session item for forked session ${forkedSessionId}`);837}838return item;839}840841private async getSessionHistory(sessionId: string, workspaceInfo: IWorkspaceInfo, token: vscode.CancellationToken) {842try {843_invalidCopilotCLISessionIdsWithErrorMessage.delete(sessionId);844const history = await this.sessionService.getChatHistory({ sessionId, workspace: workspaceInfo }, token);845return history;846} catch (error) {847if (!isUnknownEventTypeError(error)) {848throw error;849}850851const partialHistory = await this.sessionService.tryGetPartialSessionHistory(sessionId);852if (partialHistory) {853_invalidCopilotCLISessionIdsWithErrorMessage.set(sessionId, error.message || String(error));854return partialHistory;855}856857throw error;858}859}860861async provideChatSessionProviderOptions(): Promise<vscode.ChatSessionProviderOptions> {862const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];863864if (this._selectedRepoForBranches && isIsolationOptionFeatureEnabled(this.configurationService)) {865optionGroups.push({866id: ISOLATION_OPTION_ID,867name: l10n.t('Isolation'),868description: l10n.t('Pick Isolation Mode'),869items: [870{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },871{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },872]873});874}875876// Handle repository options based on workspace type877if (this.isUntitledWorkspace()) {878// For untitled workspaces, show last used repositories and "Open Repository..." command879const repositories = await this.folderMruService.getRecentlyUsedFolders(CancellationToken.None);880const items = folderMRUToChatProviderOptions(repositories);881items.splice(MAX_MRU_ENTRIES); // Limit to max entries882883if (this._lastUsedFolderIdInUntitledWorkspace && !items.some(repo => repo.id === this._lastUsedFolderIdInUntitledWorkspace)) {884const uri = Uri.file(this._lastUsedFolderIdInUntitledWorkspace);885items.unshift(toWorkspaceFolderOptionItem(uri, basename(uri)));886}887888const commands: vscode.Command[] = [];889commands.push({890command: OPEN_REPOSITORY_COMMAND_ID,891title: l10n.t('Browse folders...')892});893894optionGroups.push({895id: REPOSITORY_OPTION_ID,896name: l10n.t('Folder'),897description: l10n.t('Pick Folder'),898items,899commands900});901} else {902const repositories = this.getRepositoryOptionItems();903if (repositories.length > 1) {904optionGroups.push({905id: REPOSITORY_OPTION_ID,906name: l10n.t('Folder'),907description: l10n.t('Pick Folder'),908items: repositories909});910}911}912913if (this._selectedRepoForBranches && (isBranchOptionFeatureEnabled(this.configurationService) || (await this.isWorktreeIsolationSelected()))) {914const branchItems = await this.getBranchOptionItems(true);915if (branchItems.length > 0) {916optionGroups.push({917id: BRANCH_OPTION_ID,918name: l10n.t('Branch'),919description: l10n.t('Pick Branch'),920items: branchItems,921// icon: new vscode.ThemeIcon('git-branch')922});923}924}925926this._displayedOptionIds.clear();927optionGroups.forEach(group => {928this._displayedOptionIds.add(group.id);929});930return { optionGroups };931}932933private _branchRepositoryOptions?: { repoUri: Uri; items: Promise<vscode.ChatSessionProviderOptionItem[]> };934private async getBranchOptionItems(overrideListBranches = false): Promise<vscode.ChatSessionProviderOptionItem[]> {935if (!this._selectedRepoForBranches) {936return [];937}938939if (!overrideListBranches && !isBranchOptionFeatureEnabled(this.configurationService)) {940return [];941}942943const { repoUri, headBranchName } = this._selectedRepoForBranches;944if (!this._branchRepositoryOptions || !isEqual(repoUri, this._branchRepositoryOptions.repoUri)) {945this._branchRepositoryOptions = {946repoUri,947items: this.getBranchOptionItemsForRepository(repoUri, headBranchName)948};949}950return this._branchRepositoryOptions.items;951}952953private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey<string>();954private async getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]> {955const key = `${repoUri.toString()}${headBranchName}`;956return this._getBranchOptionItemsForRepositorySequencer.queue(key, async () => {957958const refs = await this.gitService.getRefs(repoUri, { sort: 'committerdate' });959960// Filter to local branches only (RefType.Head === 0)961const localBranches = refs.filter(ref => ref.type === 0 /* RefType.Head */ && ref.name);962963// Build items with HEAD branch first964const items: vscode.ChatSessionProviderOptionItem[] = [];965let headItem: vscode.ChatSessionProviderOptionItem | undefined;966967for (const ref of localBranches) {968const isHead = ref.name === headBranchName;969const item: vscode.ChatSessionProviderOptionItem = {970id: ref.name!,971name: ref.name!,972icon: new vscode.ThemeIcon('git-branch'),973// default: isHead974};975if (isHead) {976headItem = item;977} else {978items.push(item);979}980}981982if (headItem) {983items.unshift(headItem);984}985986return items;987});988}989990/**991* Check if the current workspace is untitled (has no workspace folders).992*/993private isUntitledWorkspace(): boolean {994return this.workspaceService.getWorkspaceFolders().length === 0;995}996997/**998* Check if the current session has worktree isolation selected.999* Used to determine whether the branch picker should be shown.1000*/1001private async isWorktreeIsolationSelected(): Promise<boolean> {1002if (!isIsolationOptionFeatureEnabled(this.configurationService)) {1003return true;1004}10051006if (!this._currentSessionId) {1007return false;1008}10091010const sessionId = this._currentSessionId;1011const cached = _sessionWorktreeIsolationCache.get(sessionId);1012if (typeof cached === 'boolean') {1013return cached;1014}10151016if (isUntitledSessionId(sessionId)) {1017const isWorktree = _sessionIsolation.get(sessionId) === IsolationMode.Worktree;1018_sessionWorktreeIsolationCache.set(sessionId, isWorktree);1019return isWorktree;1020}10211022if (_sessionIsolation.get(sessionId) === IsolationMode.Worktree) {1023_sessionWorktreeIsolationCache.set(sessionId, true);1024return true;1025}10261027const folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, undefined, CancellationToken.None);1028const isWorktree = !!folderInfo.worktreeProperties;1029_sessionWorktreeIsolationCache.set(sessionId, isWorktree);1030return isWorktree;1031}10321033private getRepositoryOptionItems() {1034// Exclude worktrees from the repository list1035const repositories = this.gitService.repositories1036.filter(repository => repository.kind !== 'worktree')1037.filter(repository => {1038if (this.isUntitledWorkspace()) {1039return true;1040}1041// Only include repositories that belong to one of the workspace folders1042return this.workspaceService.getWorkspaceFolder(repository.rootUri) !== undefined;1043});10441045const repoItems = repositories1046.map(repository => toRepositoryOptionItem(repository));10471048// In multi-root workspaces, also include workspace folders that don't have any git repos1049const workspaceFolders = this.workspaceService.getWorkspaceFolders();1050if (workspaceFolders.length) {1051// Find workspace folders that contain git repos1052const foldersWithRepos = new Set<string>();1053for (const repo of repositories) {1054const folder = this.workspaceService.getWorkspaceFolder(repo.rootUri);1055if (folder) {1056foldersWithRepos.add(folder.fsPath);1057}1058}10591060// Add workspace folders that don't have any git repos1061for (const folder of workspaceFolders) {1062if (!foldersWithRepos.has(folder.fsPath)) {1063const folderName = this.workspaceService.getWorkspaceFolderName(folder);1064repoItems.push(toWorkspaceFolderOptionItem(folder, folderName));1065}1066}1067}10681069return repoItems.sort((a, b) => a.name.localeCompare(b.name));1070}107110721073// Handle option changes for a session (store current state in a map)1074async provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, token: vscode.CancellationToken): Promise<void> {1075const sessionId = SessionIdForCLI.parse(resource);1076this._currentSessionId = sessionId;1077const wasBranchOptionShow = !!this._selectedRepoForBranches;1078let triggerProviderOptionsChange = false;1079for (const update of updates) {1080if (update.optionId === PERMISSION_LEVEL_OPTION_ID) {1081const level = typeof update.value === 'string' ? update.value : undefined;1082this._getActiveSessionForResourceId(sessionId)?.setPermissionLevel(level);1083} else if (update.optionId === REPOSITORY_OPTION_ID && typeof update.value === 'string' && this.sessionItemProvider.isNewSession(sessionId)) {1084const folder = vscode.Uri.file(update.value);1085if (isEqual(folder, this._selectedRepoForBranches?.repoUri)) {1086continue;1087}10881089_sessionBranch.delete(sessionId);10901091if ((await checkPathExists(folder, this.fileSystem))) {1092this.trackLastUsedFolderInWelcomeView(folder);1093this.folderRepositoryManager.setNewSessionFolder(sessionId, folder);10941095// Check if the selected folder is a git repo to show/hide branch dropdown1096const repoInfo = await this.folderRepositoryManager.getRepositoryInfo(folder, token);1097this._selectedRepoForBranches = repoInfo.repository1098? { repoUri: repoInfo.repository, headBranchName: repoInfo.headBranchName }1099: undefined;11001101// When switching to a new repository, we need to update the branch selection for the session. Push an1102// update to the session to select the first branch in the new repo and then we will fire an event so1103// that the branches from the new repository are loaded in the dropdown.1104if (this._selectedRepoForBranches && updates.length === 1) {1105const sessionChanges: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [];11061107const branchItems = await this.getBranchOptionItems();1108if (branchItems.length > 0) {1109const branchItem = branchItems[0];1110_sessionBranch.set(sessionId, branchItem.id);11111112sessionChanges.push({1113optionId: BRANCH_OPTION_ID,1114value: {1115id: branchItem.id,1116name: branchItem.name,1117icon: new vscode.ThemeIcon('git-branch')1118}1119});1120}11211122if (sessionChanges.length > 0) {1123this.notifySessionOptionsChange(resource, sessionChanges);1124}11251126// Update all options1127triggerProviderOptionsChange = true;1128}1129} else {1130await this.folderMruService.deleteRecentlyUsedFolder(folder);1131const message = l10n.t('The path \'{0}\' does not exist on this computer.', folder.fsPath);1132vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message });1133const defaultRepo = await this.getDefaultUntitledSessionRepositoryOption(sessionId, token);1134if (defaultRepo && !isEqual(folder, defaultRepo)) {1135this.trackLastUsedFolderInWelcomeView(defaultRepo);1136this.folderRepositoryManager.setNewSessionFolder(sessionId, defaultRepo);1137const changes: { optionId: string; value: string }[] = [];1138changes.push({ optionId: REPOSITORY_OPTION_ID, value: defaultRepo.fsPath });1139this.notifySessionOptionsChange(resource, changes);1140}1141triggerProviderOptionsChange = true;1142this._selectedRepoForBranches = undefined;1143}1144} else if (update.optionId === BRANCH_OPTION_ID) {1145if (typeof update.value === 'string' && update.value === _sessionBranch.get(sessionId)) {1146continue;1147}1148_sessionBranch.set(sessionId, update.value);1149} else if (update.optionId === ISOLATION_OPTION_ID) {1150if (typeof update.value === 'string' && update.value === _sessionIsolation.get(sessionId)) {1151continue;1152}1153_sessionIsolation.set(sessionId, update.value as IsolationMode);1154if (typeof update.value === 'string') {1155void this.context.globalState.update(LAST_USED_ISOLATION_OPTION_KEY, update.value);1156}1157triggerProviderOptionsChange = true;11581159// When switching to worktree, push a default branch selection to the session1160// so the branch picker renders. When switching to workspace, remove it.1161const sessionChanges: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [];1162if (update.value === IsolationMode.Worktree && isBranchOptionFeatureEnabled(this.configurationService)) {1163const branchItems = await this.getBranchOptionItems();1164if (branchItems.length > 0) {1165const branch = _sessionBranch.get(sessionId) ?? branchItems[0].id;1166_sessionBranch.set(sessionId, branch);1167const branchItem = branchItems.find(b => b.id === branch) ?? branchItems[0];1168sessionChanges.push({1169optionId: BRANCH_OPTION_ID,1170value: {1171id: branchItem.id,1172name: branchItem.name,1173icon: new vscode.ThemeIcon('git-branch')1174}1175});1176}1177} else if (update.value === 'workspace') {1178_sessionBranch.delete(sessionId);1179}1180if (sessionChanges.length > 0) {1181this.notifySessionOptionsChange(resource, sessionChanges);1182}1183}1184}1185const isBranchOptionShow = !!this._selectedRepoForBranches;1186if (wasBranchOptionShow !== isBranchOptionShow || triggerProviderOptionsChange) {1187this.notifyProviderOptionsChange();1188}1189}11901191private _getActiveSessionForResourceId(sessionId: string): ICopilotCLISession | undefined {1192return this._activeSessionsById.get(this.sessionItemProvider.untitledSessionIdMapping.get(sessionId) ?? sessionId)1193?? this._activeSessionsById.get(sessionId);1194}11951196trackActiveSession(resourceSessionId: string, session: ICopilotCLISession): void {1197this._activeSessionsById.set(resourceSessionId, session);1198this._activeSessionsById.set(session.sessionId, session);1199}12001201untrackActiveSession(resourceSessionId: string | undefined, session: ICopilotCLISession | undefined, hasPendingRequests: boolean): void {1202if (!session || hasPendingRequests) {1203return;1204}12051206if (resourceSessionId && this._activeSessionsById.get(resourceSessionId) === session) {1207this._activeSessionsById.delete(resourceSessionId);1208}1209if (this._activeSessionsById.get(session.sessionId) === session) {1210this._activeSessionsById.delete(session.sessionId);1211}1212}12131214}12151216function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {1217const repositoryUri = isUri(repository) ? repository : repository.rootUri;1218const repositoryIcon = isUri(repository) ? 'repo' : repository.kind === 'repository' ? 'repo' : 'archive';1219const repositoryName = repositoryUri.path.split('/').pop() ?? repositoryUri.toString();12201221return {1222id: repositoryUri.fsPath,1223name: repositoryName,1224icon: new vscode.ThemeIcon(repositoryIcon),1225default: isDefault1226} satisfies vscode.ChatSessionProviderOptionItem;1227}122812291230function toWorkspaceFolderOptionItem(workspaceFolderUri: URI, name: string): ChatSessionProviderOptionItem {1231return {1232id: workspaceFolderUri.fsPath,1233name: name,1234icon: new vscode.ThemeIcon('folder'),1235} satisfies vscode.ChatSessionProviderOptionItem;1236}12371238export class CopilotCLIChatSessionParticipant extends Disposable {12391240constructor(1241private readonly contentProvider: CopilotCLIChatSessionContentProvider,1242private readonly promptResolver: CopilotCLIPromptResolver,1243private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider,1244private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined,1245private readonly branchNameGenerator: GitBranchNameGenerator | undefined,1246@IGitService private readonly gitService: IGitService,1247@ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels,1248@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,1249@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,1250@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,1251@IChatSessionWorktreeCheckpointService private readonly copilotCLIWorktreeCheckpointService: IChatSessionWorktreeCheckpointService,1252@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,1253@ITelemetryService private readonly telemetryService: ITelemetryService,1254@ILogService private readonly logService: ILogService,1255@IPromptsService private readonly promptsService: IPromptsService,1256@IChatDelegationSummaryService private readonly chatDelegationSummaryService: IChatDelegationSummaryService,1257@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,1258@IConfigurationService private readonly configurationService: IConfigurationService,1259@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,1260@IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore,1261@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,1262@IOctoKitService private readonly octoKitService: IOctoKitService,1263) {1264super();1265}12661267createHandler(): ChatExtendedRequestHandler {1268return this.handleRequest.bind(this);1269}12701271private readonly contextForRequest = new Map<string, {1272prompt: string; attachments: Attachment[]; model?: {1273model: string;1274reasoningEffort?: string | undefined;1275};1276}>();12771278/**1279* Map to track pending requests for untitled sessions.1280* Key = Untitled Session Id1281* Value = Map of Request Id to the Promise of the request being handled1282* So if we have multiple requests (can happen when steering) for the same untitled session.1283*/1284private readonly pendingRequestsForUntitledSessions = new Map<string, Map<string, Promise<vscode.ChatResult | void>>>();12851286/**1287* Tracks in-flight requests per session so we can coordinate worktree1288* commit / PR handling and cleanup.1289*1290* We generally cannot have parallel requests for the same session, but when1291* steering is involved there can be multiple requests in flight for a1292* single session (the original request continues running while steering1293* requests are processed). This map records all active requests for each1294* session so that any worktree-related actions are deferred until the last1295* in-flight request for that session has completed.1296*/1297private readonly pendingRequestBySession = new Map<string, Set<vscode.ChatRequest>>();12981299/**1300* Outer request handler that supports *yielding* for session steering.1301*1302* ## How steering works end-to-end1303*1304* 1. The user sends a message while the session is already processing a1305* previous request (status is `InProgress` or `NeedsInput`).1306* 2. VS Code signals this by setting `context.yieldRequested = true` on the1307* *previous* request's context object.1308* 3. This handler polls `context.yieldRequested` every 100 ms. Once detected1309* the outer `Promise.race` resolves, returning control to VS Code so it1310* can dispatch the new (steering) request.1311* 4. Crucially, the inner `handleRequestImpl` promise is **not** cancelled1312* or disposed – the original SDK session continues running in the1313* background.1314* 5. When the new request arrives, `handleRequest` on the underlying1315* {@link CopilotCLISession} detects the session is still busy and routes1316* through `_handleRequestSteering`, which sends the new prompt with1317* `mode: 'immediate'` and waits for both the steering send and the1318* original request to complete.1319*/1320private async handleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> {1321const disposables = new DisposableStore();1322try {1323const handled = this.handleRequestImpl(request, context, stream, token);1324if (context.chatSessionContext) {1325const { chatSessionContext } = context;1326const { resource } = chatSessionContext.chatSessionItem;1327const id = SessionIdForCLI.parse(resource);1328const isUntitled = this.sessionItemProvider.isNewSession(id);1329if (isUntitled) {1330const promises = this.pendingRequestsForUntitledSessions.get(id) ?? new Map<string, Promise<vscode.ChatResult | void>>();1331promises.set(request.id, handled);1332this.pendingRequestsForUntitledSessions.set(id, promises);1333}1334}1335const interval = disposables.add(new IntervalTimer());1336const yielded = new DeferredPromise<void>();1337interval.cancelAndSet(() => {1338if (context.yieldRequested) {1339yielded.complete();1340}1341}, CHECK_FOR_STEERING_DELAY);13421343return await Promise.race([yielded.p, handled]);1344} finally {1345disposables.dispose();1346}1347}13481349private sendTelemetryForHandleRequest(request: vscode.ChatRequest, context: vscode.ChatContext): void {1350const { chatSessionContext } = context;1351const hasChatSessionItem = String(!!chatSessionContext?.chatSessionItem);1352const isUntitled = String(chatSessionContext?.isUntitled);1353const hasDelegatePrompt = String(request.command === 'delegate');13541355/* __GDPR__1356"copilotcli.chat.invoke" : {1357"owner": "joshspicer",1358"comment": "Event sent when a CopilotCLI chat request is made.",1359"chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The unique chat request ID." },1360"hasChatSessionItem": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Invoked with a chat session item." },1361"isUntitled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the chat session is untitled." },1362"hasDelegatePrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the prompt is a /delegate command." }1363}1364*/1365this.telemetryService.sendMSFTTelemetryEvent('copilotcli.chat.invoke', {1366chatRequestId: request.id,1367hasChatSessionItem,1368isUntitled,1369hasDelegatePrompt1370});1371}13721373private async handleRequestImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> {1374let { chatSessionContext } = context;1375const disposables = new DisposableStore();1376let sessionId: string | undefined = undefined;1377let sessionParentId: string | undefined = undefined;1378let sessionPermissionLevel: string | undefined = undefined;1379let sdkSessionId: string | undefined = undefined;1380let activeSession: ICopilotCLISession | undefined;1381try {13821383const initialOptions = chatSessionContext?.initialSessionOptions;1384if (initialOptions && chatSessionContext) {1385if (initialOptions.length > 0) {1386const sessionResource = chatSessionContext.chatSessionItem.resource;1387const sessionId = SessionIdForCLI.parse(sessionResource);1388for (const opt of initialOptions) {1389const value = typeof opt.value === 'string' ? opt.value : opt.value.id;1390if (opt.optionId === REPOSITORY_OPTION_ID && value && this.sessionItemProvider.isNewSession(sessionId)) {1391this.contentProvider.trackLastUsedFolderInWelcomeView(vscode.Uri.file(value));1392this.folderRepositoryManager.setNewSessionFolder(sessionId, vscode.Uri.file(value));1393} else if (opt.optionId === BRANCH_OPTION_ID && value) {1394_sessionBranch.set(sessionId, value);1395} else if (opt.optionId === ISOLATION_OPTION_ID && value) {1396_sessionIsolation.set(sessionId, value as IsolationMode);1397} else if (opt.optionId === PERMISSION_LEVEL_OPTION_ID && value) {1398sessionPermissionLevel = value;1399} else if (opt.optionId === PARENT_SESSION_OPTION_ID && value) {1400sessionParentId = value;1401}1402}1403}1404}14051406if (!chatSessionContext && SessionIdForCLI.isCLIResource(request.sessionResource)) {1407/**1408* Work around for bug in core, context cannot be empty, but it is.1409* This happens when we delegate from another chat and start a background agent,1410* 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.1411* & then we end up in an inifinite loop of delegating requests.1412*/1413const id = SessionIdForCLI.parse(request.sessionResource);1414if (this.contextForRequest.has(id)) {1415chatSessionContext = {1416chatSessionItem: {1417label: request.prompt,1418resource: request.sessionResource,1419},1420isUntitled: false,1421initialSessionOptions: undefined,1422inputState: {1423groups: [],1424sessionResource: undefined,1425onDidDispose: Event.None,1426onDidChange: Event.None1427}1428};1429context = {1430chatSessionContext,1431history: [],1432yieldRequested: false1433} satisfies vscode.ChatContext;1434}1435}14361437this.sendTelemetryForHandleRequest(request, context);14381439const [authInfo,] = await Promise.all([this.copilotCLISDK.getAuthInfo().catch((ex) => this.logService.error(ex, 'Authorization failed')), this.lockRepoOptionForSession(context, token)]);1440if (!authInfo) {1441this.logService.error(`Authorization failed`);1442throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));1443}1444if ((authInfo.type === 'token' && !authInfo.token) && !this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl)) {1445this.logService.error(`Authorization failed`);1446throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));1447}14481449if (!chatSessionContext || !SessionIdForCLI.isCLIResource(request.sessionResource)) {1450// Delegating from another chat session1451return await this.handleDelegationFromAnotherChat(request, undefined, request.references, context, stream, authInfo, token);1452}14531454const { resource } = chatSessionContext.chatSessionItem;1455const id = SessionIdForCLI.parse(resource);1456sessionId = id;1457const isUntitled = chatSessionContext.isUntitled;1458const invalidSessionMessage = _invalidCopilotCLISessionIdsWithErrorMessage.get(id);1459if (invalidSessionMessage) {1460const { issueUrl } = getSessionLoadFailureIssueInfo(invalidSessionMessage);1461const warningMessage = new vscode.MarkdownString();1462warningMessage.appendMarkdown(l10n.t({1463message: "Failed loading this session. If this issue persists, please [report an issue]({issueUrl}). \nError: ",1464args: { issueUrl },1465comment: [`{Locked=']({'}`]1466}));1467warningMessage.appendText(invalidSessionMessage);1468stream.warning(warningMessage);1469return {};1470}14711472// Check if we have context stored for this request1473const contextForRequest = this.contextForRequest.get(sessionId);1474this.contextForRequest.delete(sessionId);1475const [model, agent] = await Promise.all([1476contextForRequest?.model ? Promise.resolve(contextForRequest.model) : this.getModelId(request, token),1477this.getAgent(id, request, token),1478]);14791480const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined);1481const fakeContext: vscode.ChatContext = {1482history: [requestTurn],1483yieldRequested: false,1484};1485const newBranch = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : undefined;14861487const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId, permissionLevel: sessionPermissionLevel }, disposables, token);1488const session = sessionResult.session;1489if (session) {1490disposables.add(session);1491}1492if (!session || token.isCancellationRequested) {1493// If user didn't trust, then reset the session options to make it read-write.1494if (!sessionResult.trusted) {1495await this.unlockRepoOptionForSession(context, token);1496}1497return {};1498}14991500if (context.history.length === 0) {1501// Create baseline checkpoint when handling the first request1502await this.copilotCLIWorktreeCheckpointService.handleRequest(session.object.sessionId);1503}15041505sdkSessionId = session.object.sessionId;1506activeSession = session.object;1507this.contentProvider.trackActiveSession(sessionId, activeSession);1508const modeInstructions = this.createModeInstructions(request);1509this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));15101511// Lock the repo option with more accurate information.1512// Previously we just updated it with details of the folder.1513// If user has selected a repo, then update with repo information (right icons, etc).1514if (isUntitled) {1515void this.lockRepoOptionForSession(context, token);1516this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token)1517.then(title => title ? this.sessionService.updateSessionSummary(session.object.sessionId, title) : undefined)1518.catch(ex => this.logService.error(ex, 'Failed to generate custom session title'));1519}1520const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set<vscode.ChatRequest>();1521requestsForSession.add(request);1522this.pendingRequestBySession.set(session.object.sessionId, requestsForSession);15231524if (request.command === 'delegate') {1525await this.handleDelegationToCloud(session.object, request, context, stream, token);1526} else if (contextForRequest) {1527// This is a request that was created in createCLISessionAndSubmitRequest with attachments already resolved.1528const { prompt, attachments } = contextForRequest;1529await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);1530await this.commitWorktreeChangesIfNeeded(request, session.object, token);1531} else if (request.command && !request.prompt && !isUntitled) {1532const input = (copilotCLICommands as readonly string[]).includes(request.command)1533? { command: request.command as CopilotCLICommand, prompt: '' }1534: { prompt: `/${request.command}` };1535await session.object.handleRequest(request, input, [], model, authInfo, token);1536await this.commitWorktreeChangesIfNeeded(request, session.object, token);1537} else if (request.prompt && Object.values(builtinSlashSCommands).some(command => request.prompt.startsWith(command))) {1538// Sessions app built-in slash commands1539const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token);1540await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);1541await this.commitWorktreeChangesIfNeeded(request, session.object, token);1542} else {1543// Construct the full prompt with references to be sent to CLI.1544const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token);1545await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);1546await this.commitWorktreeChangesIfNeeded(request, session.object, token);1547}15481549// Build the result before the untitled-session swap below. After the swap,1550// the chat UI reloads history from the SDK and discards the in-memory1551// result, which would drop our `details` field on the first request.1552const models = await this.copilotCLIModels.getModels().catch(ex => {1553this.logService.error(ex, 'Failed to get models');1554return [];1555});1556const modelInfo = models.find(m => m.id === model?.model);1557const result: vscode.ChatResult = modelInfo1558? { details: formatModelDetails(modelInfo) }1559: {};15601561if (isUntitled && !token.isCancellationRequested) {1562// Its possible the user tried steering, in that case, we should NOT swap the session item because the session.1563// Else the messages may get lost (wait CHECK_FOR_STEERING_DELAYms to check if we have pending steering requests)1564await new Promise<void>(resolve => disposableTimeout(() => resolve(), CHECK_FOR_STEERING_DELAY, this._store));1565const pendingRequests = this.pendingRequestsForUntitledSessions.get(id);1566if (pendingRequests) {1567pendingRequests.delete(request.id);1568// If we have more requests, that means we had the original request as well as at least one another steering request.1569// Lets not swap anything here, until all pending requests have been completed.1570if (pendingRequests.size > 0) {1571return result;1572}1573}15741575// Delete old information stored for untitled session id.1576_sessionBranch.delete(id);1577_sessionIsolation.delete(id);1578this.sessionItemProvider.untitledSessionIdMapping.delete(id);1579this.sessionItemProvider.sdkToUntitledUriMapping.delete(session.object.sessionId);1580this.folderRepositoryManager.deleteNewSessionFolder(id);1581this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { resource: SessionIdForCLI.getResource(session.object.sessionId), label: request.prompt });1582}15831584return result;1585} catch (ex) {1586if (isCancellationError(ex)) {1587return {};1588}1589throw ex;1590}1591finally {1592if (sdkSessionId) {1593const requestsForSession = this.pendingRequestBySession.get(sdkSessionId);1594if (requestsForSession) {1595requestsForSession.delete(request);1596if (requestsForSession.size === 0) {1597this.pendingRequestBySession.delete(sdkSessionId);1598}1599}1600}1601this.contentProvider.untrackActiveSession(sessionId, activeSession, sdkSessionId ? this.pendingRequestBySession.has(sdkSessionId) : false);1602if (chatSessionContext?.chatSessionItem.resource) {1603this.sessionItemProvider.notifySessionsChange();1604}1605disposables.dispose();1606}1607}16081609private async lockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) {1610const { chatSessionContext } = context;1611if (!chatSessionContext?.isUntitled) {1612return;1613}1614const { resource } = chatSessionContext.chatSessionItem;1615// If we have a real session id that was mapped to this untitled session, then use that.1616// This way we can get the latest information associated with the real session.1617const parsedId = SessionIdForCLI.parse(resource);1618const id = this.sessionItemProvider.untitledSessionIdMapping.get(parsedId) ?? parsedId;1619const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token);1620if (folderInfo.folder) {1621const folderName = basename(folderInfo.folder);1622const option = folderInfo.repository ? toRepositoryOptionItem(folderInfo.repository) : toWorkspaceFolderOptionItem(folderInfo.folder, folderName);1623const changes: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [1624{ optionId: REPOSITORY_OPTION_ID, value: { ...option, locked: true } }1625];1626// Also lock the branch option1627const selectedBranch = folderInfo.worktreeProperties?.branchName ?? _sessionBranch.get(id);1628if (selectedBranch && isBranchOptionFeatureEnabled(this.configurationService)) {1629changes.push({1630optionId: BRANCH_OPTION_ID,1631value: {1632id: selectedBranch,1633name: selectedBranch,1634icon: new vscode.ThemeIcon('git-branch'),1635locked: true1636}1637});1638}1639// Also lock the isolation option if set1640const selectedIsolation = _sessionIsolation.get(id);1641if (selectedIsolation && isIsolationOptionFeatureEnabled(this.configurationService)) {1642changes.push({1643optionId: ISOLATION_OPTION_ID,1644value: {1645id: selectedIsolation,1646name: selectedIsolation === IsolationMode.Worktree1647? l10n.t('Worktree')1648: l10n.t('Workspace'),1649icon: new vscode.ThemeIcon(selectedIsolation === IsolationMode.Worktree ? 'worktree' : 'folder'),1650locked: true1651}1652});1653}1654this.contentProvider.notifySessionOptionsChange(resource, changes);1655}1656}16571658private async unlockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) {1659const { chatSessionContext } = context;1660if (!chatSessionContext?.isUntitled) {1661return;1662}1663const { resource } = chatSessionContext.chatSessionItem;1664const id = SessionIdForCLI.parse(resource);1665const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token);1666if (folderInfo.folder) {1667const option = folderInfo.repository?.fsPath ?? folderInfo.folder.fsPath;1668const changes: { optionId: string; value: string }[] = [1669{ optionId: REPOSITORY_OPTION_ID, value: option }1670];1671// Also unlock the branch option if a branch was selected1672const selectedBranch = _sessionBranch.get(id);1673if (selectedBranch && isBranchOptionFeatureEnabled(this.configurationService)) {1674changes.push({ optionId: BRANCH_OPTION_ID, value: selectedBranch });1675}1676// Also unlock the isolation option if set1677const selectedIsolation = _sessionIsolation.get(id);1678if (selectedIsolation && isIsolationOptionFeatureEnabled(this.configurationService)) {1679changes.push({ optionId: ISOLATION_OPTION_ID, value: selectedIsolation });1680}1681this.contentProvider.notifySessionOptionsChange(resource, changes);1682}1683}16841685private async commitWorktreeChangesIfNeeded(request: vscode.ChatRequest, session: ICopilotCLISession, token: vscode.CancellationToken): Promise<void> {1686const pendingRequests = this.pendingRequestBySession.get(session.sessionId);1687if (pendingRequests && pendingRequests.size > 1) {1688// We still have pending requests for this session, which means the user has done some steering.1689// Wait for all requests to complete, the last request to complete will handle the commit.1690pendingRequests.delete(request);1691return;1692}16931694if (token.isCancellationRequested) {1695pendingRequests?.delete(request);1696return;1697}16981699try {1700if (session.status === vscode.ChatSessionStatus.Completed) {1701const workingDirectory = getWorkingDirectory(session.workspace);1702if (isIsolationEnabled(session.workspace)) {1703// When isolation is enabled and we are using a git worktree, so we commit1704// all the changes in the worktree directory when the session is completed.1705// Note that if the worktree supports checkpoints, then the commit will be1706// done in the checkpoint so that users can easily see the changes made in1707// the worktree and also revert back if needed.1708await this.copilotCLIWorktreeManagerService.handleRequestCompleted(session.sessionId);1709} else if (workingDirectory) {1710// When isolation is not enabled, we are operating in the workspace directly,1711// so we stage all the changes in the workspace directory when the session is1712// completed1713await this.workspaceFolderService.handleRequestCompleted(session.sessionId);1714}17151716// Create checkpoint - we create a checkpoint for the worktree changes so that users1717// can easily see the changes made in the worktree and also revert back if needed. This1718// is used if worktree isolation is enabled, and auto-commit is disabled or workspace1719// isolation is enabled.1720await this.copilotCLIWorktreeCheckpointService.handleRequestCompleted(session.sessionId, request.id);1721if (workingDirectory) {1722void 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'));1723}1724}17251726void this.handlePullRequestCreated(session).catch(ex => this.logService.error(ex, 'Failed to handle pull request creation'));1727} finally {1728pendingRequests?.delete(request);1729}1730}17311732private static readonly _PR_DETECTION_RETRY_COUNT = 5;1733private static readonly _PR_DETECTION_INITIAL_DELAY_MS = 2_000;17341735private async handlePullRequestCreated(session: ICopilotCLISession): Promise<void> {1736const sessionId = session.sessionId;1737let prUrl = session.createdPullRequestUrl;1738let prState = '';17391740this.logService.debug(`[CopilotCLIChatSessionParticipant] handlePullRequestCreated for ${sessionId}: createdPullRequestUrl=${prUrl ?? 'none'}`);17411742const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);17431744if (!worktreeProperties || worktreeProperties.version !== 2) {1745return;1746}17471748if (!prUrl) {1749// Only attempt retry detection if the session has v2 worktree properties1750// with branch info — v1 worktrees can't store PR URLs, and sessions1751// without worktree properties have nothing to look up.1752if (worktreeProperties.branchName && worktreeProperties.repositoryPath) {1753this.logService.debug(`[CopilotCLIChatSessionParticipant] No PR URL from session, attempting retry detection for ${sessionId}, branch=${worktreeProperties.branchName}`);1754const prResult = await this.detectPullRequestWithRetry(sessionId);1755prUrl = prResult?.url;1756prState = prResult?.state ?? prUrl ? 'open' : '';1757} else {1758this.logService.debug(`[CopilotCLIChatSessionParticipant] Skipping retry detection for ${sessionId}: branch=${worktreeProperties.branchName ?? 'none'}, repoPath=${!!worktreeProperties.repositoryPath}`);1759}1760}17611762if (!prUrl) {1763this.logService.debug(`[CopilotCLIChatSessionParticipant] No PR detected for ${sessionId} after all attempts`);1764return;1765}17661767try {1768await this.copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, {1769...worktreeProperties,1770pullRequestUrl: prUrl,1771pullRequestState: prState,1772changes: undefined,1773});1774this.sessionItemProvider.notifySessionsChange();1775} catch (error) {1776const err = error instanceof Error ? error : new Error(String(error));1777this.logService.error(err, `Failed to persist pull request metadata for session ${sessionId}`);1778}1779}17801781/**1782* Attempts to detect a pull request for a freshly-completed session using1783* exponential backoff. The GitHub API may not have indexed the PR immediately1784* after `gh pr create` returns, so we retry with increasing delays:1785* attempt 1: 2s, attempt 2: 4s, attempt 3: 8s.1786*/1787private async detectPullRequestWithRetry(sessionId: string): Promise<{ url: string; state: string } | undefined> {1788const maxRetries = CopilotCLIChatSessionParticipant._PR_DETECTION_RETRY_COUNT;1789const initialDelay = CopilotCLIChatSessionParticipant._PR_DETECTION_INITIAL_DELAY_MS;17901791for (let attempt = 0; attempt < maxRetries; attempt++) {1792const delay = initialDelay * Math.pow(2, attempt);1793this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detection retry for ${sessionId}: attempt ${attempt + 1}/${maxRetries}, waiting ${delay}ms`);1794await new Promise<void>(resolve => setTimeout(resolve, delay));17951796const prResult = await this.detectPullRequestForSession(sessionId);1797if (prResult) {1798this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detected on attempt ${attempt + 1} for ${sessionId}: url=${prResult.url}, state=${prResult.state}`);1799return prResult;1800}1801}18021803this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detection exhausted all ${maxRetries} retries for ${sessionId}`);1804return undefined;1805}18061807/**1808* Queries the GitHub API to find a pull request whose head branch matches the1809* session's worktree branch. This covers cases where the MCP tool failed to1810* report a PR URL, or the user created the PR externally (e.g., via github.com).1811*/1812private async detectPullRequestForSession(sessionId: string): Promise<{ url: string; state: string } | undefined> {1813try {1814const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);1815if (!worktreeProperties?.branchName || !worktreeProperties.repositoryPath) {1816this.logService.debug(`[CopilotCLIChatSessionParticipant] detectPullRequestForSession: missing worktree info for ${sessionId}, branch=${worktreeProperties?.branchName ?? 'none'}, repoPath=${!!worktreeProperties?.repositoryPath}`);1817return undefined;1818}18191820return await detectPullRequestFromGitHubAPI(1821worktreeProperties.branchName,1822worktreeProperties.repositoryPath,1823this.gitService,1824this.octoKitService,1825this.logService,1826);1827} catch (error) {1828this.logService.debug(`[CopilotCLIChatSessionParticipant] Failed to detect pull request via GitHub API: ${error instanceof Error ? error.message : String(error)}`);1829return undefined;1830}1831}18321833/**1834* Gets the agent to be used.1835* If the request has a prompt file (modeInstructions2) that specifies an agent, uses that agent.1836* If the prompt file specifies tools, those tools override the agent's default tools.1837* Otherwise returns undefined (no agent).1838*/1839private async getAgent(sessionId: string | undefined, request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<SweCustomAgent | undefined> {1840// If we have a prompt file that specifies an agent or tools, use that.1841if (request?.modeInstructions2) {1842const customAgent = request.modeInstructions2.uri ? await this.copilotCLIAgents.resolveAgent(request.modeInstructions2.uri.toString()) : await this.copilotCLIAgents.resolveAgent(request.modeInstructions2.name);1843if (customAgent) {1844const tools = (request.modeInstructions2.toolReferences || []).map(t => t.name);1845if (tools.length > 0) {1846customAgent.tools = tools;1847}1848return customAgent;1849}1850}1851// If not found, don't use any agent, default to empty agent.1852return undefined;1853}18541855private async getPromptInfoFromRequest(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise<ParsedPromptFile | undefined> {1856const promptFile = new ChatVariablesCollection(request.references).find(isPromptFile);1857if (!promptFile || !URI.isUri(promptFile.reference.value)) {1858return undefined;1859}1860try {1861return await this.promptsService.parseFile(promptFile.reference.value, token);1862} catch (ex) {1863this.logService.error(`Failed to parse the prompt file: ${promptFile.reference.value.toString()}`, ex);1864return undefined;1865}1866}18671868private 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 }> {1869const { resource } = chatSessionContext.chatSessionItem;1870const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource));1871const id = existingSessionId ?? SessionIdForCLI.parse(resource);1872const isNewSession = chatSessionContext.isUntitled && !existingSessionId;18731874const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, stream, request.toolInvocationToken, token, options.newBranch);1875const workingDirectory = getWorkingDirectory(workspaceInfo);1876const worktreeProperties = workspaceInfo.worktreeProperties;1877if (cancelled || token.isCancellationRequested) {1878return { session: undefined, trusted };1879}18801881const model = options.model;1882const agent = options.agent;1883const debugTargetSessionIds = extractDebugTargetSessionIds(request.references);1884const mcpServerMappings = buildMcpServerMappings(request.tools);1885const session = isNewSession ?1886await this.sessionService.createSession({ model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings, sessionParentId: options.sessionParentId }, token) :1887await this.sessionService.getSession({ sessionId: id, model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token);1888this.sessionItemProvider.notifySessionsChange();1889// TODO @DonJayamanne We need to refresh to add this new session, but we need a label.1890// So when creating a session we need a dummy label (or an initial prompt).18911892if (!session) {1893stream.warning(l10n.t('Chat session not found.'));1894return { session: undefined, trusted };1895}1896this.logService.info(`Using Copilot CLI session: ${session.object.sessionId} (isNewSession: ${isNewSession}, isolationEnabled: ${isIsolationEnabled(workspaceInfo)}, workingDirectory: ${workingDirectory}, worktreePath: ${worktreeProperties?.worktreePath})`);1897if (isNewSession) {1898this.sessionItemProvider.untitledSessionIdMapping.set(id, session.object.sessionId);1899this.sessionItemProvider.sdkToUntitledUriMapping.set(session.object.sessionId, resource);1900if (worktreeProperties) {1901void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties);1902}1903}1904const sessionWorkingDirectory = getWorkingDirectory(session.object.workspace);1905if (sessionWorkingDirectory && !isIsolationEnabled(session.object.workspace)) {1906void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, sessionWorkingDirectory.fsPath, session.object.workspace.repositoryProperties);1907}1908disposables.add(session.object.attachStream(stream));1909const permissionLevel = request.permissionLevel ?? options.permissionLevel;1910session.object.setPermissionLevel(permissionLevel);19111912return { session, trusted };1913}19141915private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<{ model: string; reasoningEffort?: string } | undefined> {1916const promptFile = request ? await this.getPromptInfoFromRequest(request, token) : undefined;1917const model = promptFile?.header?.model ? await getModelFromPromptFile(promptFile.header.model, this.copilotCLIModels) : undefined;1918if (token.isCancellationRequested) {1919return undefined;1920}1921if (model) {1922return { model };1923}1924// Get model from request.1925const preferredModelInRequest = request?.model?.id ? await this.copilotCLIModels.resolveModel(request.model.id) : undefined;1926if (preferredModelInRequest) {1927const reasoningEffort = isReasoningEffortFeatureEnabled(this.configurationService) ? request?.modelConfiguration?.[COPILOT_CLI_REASONING_EFFORT_PROPERTY] : undefined;1928return {1929model: preferredModelInRequest,1930reasoningEffort: typeof reasoningEffort === 'string' && reasoningEffort ? reasoningEffort : undefined1931};1932}1933const defaultModel = await this.copilotCLIModels.getDefaultModel();1934if (!defaultModel) {1935return undefined;1936}1937return { model: defaultModel };1938}19391940private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {1941if (!this.cloudSessionProvider) {1942stream.warning(l10n.t('No cloud agent available'));1943return;1944}19451946// Check for uncommitted changes1947const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.sessionId);1948const repositoryPath = worktreeProperties?.repositoryPath ? Uri.file(worktreeProperties.repositoryPath) : getWorkingDirectory(session.workspace);1949const repository = repositoryPath ? await this.gitService.getRepository(repositoryPath) : undefined;1950const hasChanges = (repository?.changes?.indexChanges && repository.changes.indexChanges.length > 0);19511952if (hasChanges) {1953stream.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.'));1954}19551956const prInfo = await this.cloudSessionProvider.delegate(request, stream, context, token, { prompt: request.prompt, chatContext: context });1957await this.recordPushToSession(session, `/delegate ${request.prompt}`, prInfo);19581959}19601961private async getOrInitializeWorkingDirectory(1962chatSessionContext: vscode.ChatSessionContext | undefined,1963stream: vscode.ChatResponseStream,1964toolInvocationToken: vscode.ChatParticipantToolToken,1965token: vscode.CancellationToken,1966newBranch?: Promise<string | undefined>1967): Promise<{1968workspaceInfo: IWorkspaceInfo;1969cancelled: boolean;1970trusted: boolean;1971}> {1972let folderInfo: FolderRepositoryInfo;1973if (chatSessionContext) {1974const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource));1975const id = existingSessionId ?? SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource);1976const isNewSession = chatSessionContext.isUntitled && !existingSessionId;19771978if (isNewSession) {1979// Use FolderRepositoryManager to initialize folder/repository with worktree creation1980const branch = _sessionBranch.get(id);1981const isolation = _sessionIsolation.get(id) ?? undefined;1982folderInfo = await this.folderRepositoryManager.initializeFolderRepository(id, { stream, toolInvocationToken, branch: branch ?? undefined, isolation, folder: undefined, newBranch }, token);1983} else {1984// Existing session - use getFolderRepository for resolution with trust check1985folderInfo = await this.folderRepositoryManager.getFolderRepository(id, { promptForTrust: true, stream }, token);1986}1987} else {1988// No chat session context (e.g., delegation) - initialize with active repository1989folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation: undefined, folder: undefined }, token);1990}19911992if (folderInfo.trusted === false || folderInfo.cancelled) {1993return { workspaceInfo: emptyWorkspaceInfo(), cancelled: true, trusted: folderInfo.trusted !== false };1994}19951996const workspaceInfo = Object.assign({}, folderInfo);1997return { workspaceInfo, cancelled: false, trusted: true };1998}19992000private createModeInstructions(request: vscode.ChatRequest): StoredModeInstructions | undefined {2001return request.modeInstructions2 ? {2002uri: request.modeInstructions2.uri?.toString(),2003name: request.modeInstructions2.name,2004content: request.modeInstructions2.content,2005metadata: request.modeInstructions2.metadata,2006isBuiltin: request.modeInstructions2.isBuiltin,2007} : undefined;20082009}2010private async handleDelegationFromAnotherChat(2011request: vscode.ChatRequest,2012userPrompt: string | undefined,2013otherReferences: readonly vscode.ChatPromptReference[] | undefined,2014context: vscode.ChatContext,2015stream: vscode.ChatResponseStream,2016authInfo: NonNullable<SessionOptions['authInfo']>,2017token: vscode.CancellationToken2018): Promise<vscode.ChatResult> {2019let summary: string | undefined;2020const requestPromptPromise = (async () => {2021if (this.hasHistoryToSummarize(context.history)) {2022stream.progress(l10n.t('Analyzing chat history'));2023summary = await this.chatDelegationSummaryService.summarize(context, token);2024summary = summary ? `**Summary**\n${summary}` : undefined;2025}20262027// Give priority to userPrompt if provided (e.g., from confirmation metadata)2028userPrompt = userPrompt || request.prompt;2029return summary ? `${userPrompt}\n${summary}` : userPrompt;2030})();20312032const [{ workspaceInfo, cancelled }, model, agent] = await Promise.all([2033this.getOrInitializeWorkingDirectory(undefined, stream, request.toolInvocationToken, token),2034this.getModelId(request, token), // prefer model in request, as we're delegating from another session here.2035this.getAgent(undefined, undefined, token)2036]);20372038if (cancelled || token.isCancellationRequested) {2039stream.markdown(l10n.t('Copilot CLI delegation cancelled.'));2040return {};2041}2042const workingDirectory = getWorkingDirectory(workspaceInfo);2043const worktreeProperties = workspaceInfo.worktreeProperties;2044const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token);20452046const mcpServerMappings = buildMcpServerMappings(request.tools);2047const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model: model?.model, reasoningEffort: model?.reasoningEffort, mcpServerMappings }, token);2048const modeInstructions = this.createModeInstructions(request);2049this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));2050if (summary) {2051const summaryRef = await this.chatDelegationSummaryService.trackSummaryUsage(session.object.sessionId, summary);2052if (summaryRef) {2053references.push(summaryRef);2054}2055}2056// Do not await, we want this code path to be as fast as possible.2057if (worktreeProperties) {2058void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties);2059}2060if (workingDirectory && !isIsolationEnabled(workspaceInfo)) {2061void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, workingDirectory.fsPath, workspaceInfo.repositoryProperties);2062}20632064this.contextForRequest.set(session.object.sessionId, { prompt, attachments, model });2065this.sessionItemProvider.notifySessionsChange();2066// TODO @DonJayamanne I don't think we need to refresh the list of session here just yet, or perhaps we do,2067// Same as getOrCreate session, we need a dummy title or the initial prompt to show in the sessions list.2068void vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2069resource: SessionIdForCLI.getResource(session.object.sessionId),2070prompt: userPrompt || request.prompt,2071attachedContext: references.map(ref => convertReferenceToVariable(ref, attachments))2072});20732074stream.markdown(l10n.t('A Copilot CLI session has begun working on your request. Follow its progress in the sessions list.'));20752076return {};2077}20782079private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean {2080if (!history || history.length === 0) {2081return false;2082}2083const allResponsesEmpty = history.every(turn => {2084if (turn instanceof vscode.ChatResponseTurn) {2085return turn.response.length === 0;2086}2087return true;2088});2089return !allResponsesEmpty;2090}20912092private async recordPushToSession(2093session: ICopilotCLISession,2094userPrompt: string,2095prInfo: vscode.ChatResponsePullRequestPart2096): Promise<void> {2097// Add user message event2098session.addUserMessage(userPrompt);20992100// Add assistant message event with embedded PR metadata2101const 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)}"/>`;2102session.addUserAssistantMessage(assistantMessage);2103}2104}21052106export function registerCLIChatCommands(2107copilotcliSessionItemProvider: CopilotCLIChatSessionItemProvider,2108copilotCLISessionService: ICopilotCLISessionService,2109copilotCLIWorktreeManagerService: IChatSessionWorktreeService,2110gitService: IGitService,2111gitExtensionService: IGitExtensionService,2112toolsService: IToolsService,2113copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService,2114contentProvider: CopilotCLIChatSessionContentProvider,2115folderRepositoryManager: IFolderRepositoryManager,2116cliFolderMruService: IChatFolderMruService,2117envService: INativeEnvService,2118fileSystemService: IFileSystemService,2119logService: ILogService2120): IDisposable {2121const disposableStore = new DisposableStore();2122async function deleteSessionById(sessionId: string): Promise<void> {2123const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2124const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);21252126await copilotCLISessionService.deleteSession(sessionId);2127await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(sessionId);21282129if (worktreePath) {2130const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false);2131if (worktreeExists) {2132try {2133const repository = worktree ? await gitService.getRepository(vscode.Uri.file(worktree.repositoryPath), true) : undefined;2134if (!repository) {2135throw new Error(l10n.t('No active repository found to delete worktree.'));2136}2137await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath);2138} catch (error) {2139vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error)));2140}2141}2142}2143}2144disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.delete', async (sessionItem?: vscode.ChatSessionItem) => {2145if (sessionItem?.resource) {2146const id = SessionIdForCLI.parse(sessionItem.resource);2147const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2148const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);21492150const confirmMessage = worktreePath2151? l10n.t('Are you sure you want to delete the session and its associated worktree?')2152: l10n.t('Are you sure you want to delete the session?');21532154const deleteLabel = l10n.t('Delete');2155const result = await vscode.window.showWarningMessage(2156confirmMessage,2157{ modal: true },2158deleteLabel2159);21602161if (result === deleteLabel) {2162await deleteSessionById(sessionId);2163copilotcliSessionItemProvider.notifySessionsChange();2164}2165}2166}));2167disposableStore.add(vscode.commands.registerCommand('agents.github.copilot.cli.deleteSessions', async (sessionItems?: vscode.ChatSessionItem[], options?: { skipConfirmation?: boolean }) => {2168if (!sessionItems?.length) {2169return;2170}21712172if (!options?.skipConfirmation) {2173const deleteLabel = l10n.t('Delete');2174const confirmMessage = sessionItems.length === 12175? l10n.t('Are you sure you want to delete the session?')2176: l10n.t('Are you sure you want to delete {0} sessions?', sessionItems.length);2177const result = await vscode.window.showWarningMessage(2178confirmMessage,2179{ modal: true },2180deleteLabel2181);2182if (result !== deleteLabel) {2183return;2184}2185}21862187for (const sessionItem of sessionItems) {2188if (sessionItem.resource) {2189const id = SessionIdForCLI.parse(sessionItem.resource);2190const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2191await deleteSessionById(sessionId);2192}2193}21942195copilotcliSessionItemProvider.notifySessionsChange();2196}));2197disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.resumeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => {2198if (sessionItem?.resource) {2199await copilotcliSessionItemProvider.resumeCopilotCLISessionInTerminal(sessionItem);2200}2201}));2202disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.rename', async (sessionItem?: vscode.ChatSessionItem) => {2203if (!sessionItem?.resource) {2204return;2205}2206const newTitle = await vscode.window.showInputBox({2207prompt: l10n.t('New agent session title'),2208value: sessionItem.label,2209validateInput: value => {2210if (!value.trim()) {2211return l10n.t('Title cannot be empty');2212}2213return undefined;2214}2215});2216if (newTitle) {2217const trimmedTitle = newTitle.trim();2218if (trimmedTitle) {2219const id = SessionIdForCLI.parse(sessionItem.resource);2220const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2221await copilotCLISessionService.renameSession(sessionId, trimmedTitle);2222copilotcliSessionItemProvider.notifySessionsChange();2223}2224}2225}));2226disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.setTitle', async (sessionItem?: vscode.ChatSessionItem, title?: string) => {2227if (!sessionItem?.resource || !title) {2228return;2229}2230const trimmedTitle = title.trim();2231if (trimmedTitle) {2232const id = SessionIdForCLI.parse(sessionItem.resource);2233const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2234await copilotCLISessionService.renameSession(sessionId, trimmedTitle);2235copilotcliSessionItemProvider.notifySessionsChange();2236}2237}));2238disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.newSession', async () => {2239await copilotcliSessionItemProvider.createCopilotCLITerminal('editor', l10n.t('Copilot CLI'));2240}));2241disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.newSessionToSide', async () => {2242await copilotcliSessionItemProvider.createCopilotCLITerminal('editorBeside', l10n.t('Copilot CLI'));2243}));2244disposableStore.add(vscode.commands.registerCommand(OPEN_IN_COPILOT_CLI_COMMAND_ID, async (sourceControlContext?: unknown) => {2245const rootUri = getSourceControlRootUri(sourceControlContext);2246await copilotcliSessionItemProvider.createCopilotCLITerminal('editor', l10n.t('Copilot CLI'), rootUri?.fsPath);2247}));2248disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.openWorktreeInNewWindow', async (sessionItem?: vscode.ChatSessionItem) => {2249if (!sessionItem?.resource) {2250return;2251}22522253const id = SessionIdForCLI.parse(sessionItem.resource);2254const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2255const folderInfo = await folderRepositoryManager.getFolderRepository(sessionId, undefined, CancellationToken.None);2256const folder = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;2257if (folder) {2258await vscode.commands.executeCommand('vscode.openFolder', folder, { forceNewWindow: true });2259}2260}));2261disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.openWorktreeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => {2262if (!sessionItem?.resource) {2263return;2264}22652266const id = SessionIdForCLI.parse(sessionItem.resource);2267const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2268const folderInfo = await folderRepositoryManager.getFolderRepository(sessionId, undefined, CancellationToken.None);2269const folder = folderInfo.worktree ?? folderInfo.repository ?? folderInfo.folder;2270if (folder) {2271vscode.window.createTerminal({ cwd: folder }).show();2272}2273}));2274disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.copyWorktreeBranchName', async (sessionItem?: vscode.ChatSessionItem) => {2275if (!sessionItem?.resource) {2276return;2277}22782279const id = SessionIdForCLI.parse(sessionItem.resource);2280const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;2281const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2282if (worktreeProperties?.branchName) {2283await vscode.env.clipboard.writeText(worktreeProperties.branchName);2284}2285}));2286async function selectFolder() {2287// Open folder picker dialog2288const folderUris = await vscode.window.showOpenDialog({2289canSelectFiles: false,2290canSelectFolders: true,2291canSelectMany: false,2292openLabel: l10n.t('Open Folder...'),2293});22942295return folderUris && folderUris.length > 0 ? folderUris[0] : undefined;2296}22972298function getSourceControlRootUri(sourceControlContext?: unknown): vscode.Uri | undefined {2299if (!sourceControlContext) {2300return undefined;2301}23022303if (Array.isArray(sourceControlContext)) {2304return getSourceControlRootUri(sourceControlContext[0]);2305}23062307if (isUri(sourceControlContext)) {2308return sourceControlContext;2309}23102311if (typeof sourceControlContext !== 'object') {2312return undefined;2313}23142315const candidate = sourceControlContext as {2316rootUri?: unknown;2317sourceControl?: { rootUri?: unknown };2318repository?: { rootUri?: unknown };2319};23202321if (isUri(candidate.rootUri)) {2322return candidate.rootUri;2323}23242325if (isUri(candidate.sourceControl?.rootUri)) {2326return candidate.sourceControl.rootUri;2327}23282329if (isUri(candidate.repository?.rootUri)) {2330return candidate.repository.rootUri;2331}23322333return undefined;2334}23352336disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (sessionItemResource?: vscode.Uri) => {2337if (!sessionItemResource) {2338return;2339}23402341let selectedFolderUri: Uri | undefined = undefined;2342const mruItems = await cliFolderMruService.getRecentlyUsedFolders(CancellationToken.None);23432344if (mruItems.length === 0) {2345selectedFolderUri = await selectFolder();2346} else {2347type RecentFolderQuickPickItem = vscode.QuickPickItem & ({ folderUri: vscode.Uri; openFolder: false } | { folderUri: undefined; openFolder: true });2348const items: RecentFolderQuickPickItem[] = mruItems2349.map(item => {2350const optionItem = item.repository2351? toRepositoryOptionItem(item.folder)2352: toWorkspaceFolderOptionItem(item.folder, basename(item.folder));23532354return {2355label: optionItem.name,2356description: `~/${relative(envService.userHome.fsPath, item.folder.fsPath)}`,2357iconPath: optionItem.icon,2358folderUri: item.folder,2359openFolder: false2360};2361});23622363items.unshift({2364label: l10n.t('Open Folder...'),2365iconPath: new vscode.ThemeIcon('folder-opened'),2366folderUri: undefined,2367openFolder: true2368}, {2369kind: vscode.QuickPickItemKind.Separator,2370label: '',2371folderUri: undefined,2372openFolder: true2373});23742375const selectedFolder = new DeferredPromise<Uri | undefined>();2376const disposables = new DisposableStore();2377const quickPick = disposables.add(vscode.window.createQuickPick<RecentFolderQuickPickItem>());2378quickPick.items = items;2379quickPick.placeholder = l10n.t('Select a recent folder');2380quickPick.matchOnDescription = true;2381quickPick.ignoreFocusOut = true;2382quickPick.matchOnDetail = true;2383quickPick.show();2384disposables.add(quickPick.onDidHide(() => {2385selectedFolder.complete(undefined);2386}));2387disposables.add(quickPick.onDidAccept(async () => {2388if (quickPick.selectedItems.length === 0 && !quickPick.value) {2389selectedFolder.complete(undefined);2390quickPick.hide();2391} else if (quickPick.selectedItems.length && quickPick.selectedItems[0].folderUri) {2392selectedFolder.complete(quickPick.selectedItems[0].folderUri);2393quickPick.hide();2394} else if (quickPick.selectedItems.length && quickPick.selectedItems[0].openFolder) {2395selectedFolder.complete(await selectFolder());2396quickPick.hide();2397} else if (quickPick.value) {2398const fileOrFolder = vscode.Uri.file(quickPick.value);2399try {2400const stat = await vscode.workspace.fs.stat(fileOrFolder);2401let directory: Uri | undefined = undefined;2402if (stat.type & vscode.FileType.Directory) {2403quickPick.hide();2404directory = fileOrFolder;2405} else if (stat.type & vscode.FileType.File) {2406directory = dirname(fileOrFolder);2407}2408if (directory) {2409// Possible user selected a folder thats inside an existing workspace folder.2410selectedFolder.complete(vscode.workspace.getWorkspaceFolder(directory)?.uri || directory);2411quickPick.hide();2412}2413} catch {2414// ignore2415}2416}2417}));2418selectedFolderUri = await selectedFolder.p;2419disposables.dispose();2420}24212422if (!selectedFolderUri) {2423return;2424}2425if (!(await checkPathExists(selectedFolderUri, fileSystemService))) {2426const message = l10n.t('The path \'{0}\' does not exist on this computer.', selectedFolderUri.fsPath);2427vscode.window.showErrorMessage(l10n.t('Path does not exist'), { modal: true, detail: message });2428return;2429}24302431const sessionId = SessionIdForCLI.parse(sessionItemResource);2432contentProvider.trackLastUsedFolderInWelcomeView(selectedFolderUri);2433folderRepositoryManager.setNewSessionFolder(sessionId, selectedFolderUri);24342435// Notify VS Code that the option changed2436contentProvider.notifySessionOptionsChange(sessionItemResource, [{2437optionId: REPOSITORY_OPTION_ID,2438value: selectedFolderUri.fsPath2439}]);24402441// Notify that provider options have changed so the dropdown updates2442contentProvider.notifyProviderOptionsChange();24432444}));24452446const applyChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2447const resource = sessionItemOrResource instanceof vscode.Uri2448? sessionItemOrResource2449: sessionItemOrResource?.resource;24502451if (!resource) {2452return;2453}24542455try {2456// Apply changes2457const sessionId = SessionIdForCLI.parse(resource);2458await copilotCLIWorktreeManagerService.applyWorktreeChanges(sessionId);24592460// Close the multi-file diff editor if it's open2461const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2462const worktreePath = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : undefined;24632464if (worktreePath) {2465// Select the tabs to close2466const multiDiffTabToClose = vscode.window.tabGroups.all.flatMap(g => g.tabs)2467.filter(({ input }) => input instanceof vscode.TabInputTextMultiDiff && input.textDiffs.some(input =>2468extUri.isEqualOrParent(vscode.Uri.file(input.original.fsPath), worktreePath, true) ||2469extUri.isEqualOrParent(vscode.Uri.file(input.modified.fsPath), worktreePath, true)));24702471if (multiDiffTabToClose.length > 0) {2472// Close the tabs2473await vscode.window.tabGroups.close(multiDiffTabToClose, true);2474}2475}24762477// Pick up new git state2478copilotcliSessionItemProvider.notifySessionsChange();2479} catch (error) {2480vscode.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 });2481}2482};24832484disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.applyCopilotCLIAgentSessionChanges', applyChanges));2485disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.applyCopilotCLIAgentSessionChanges.apply', applyChanges));24862487const mergeChanges = async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri, syncWithRemote: boolean = false) => {2488const resource = sessionItemOrResource instanceof vscode.Uri2489? sessionItemOrResource2490: sessionItemOrResource?.resource;24912492if (!resource) {2493return;2494}24952496let branchName: string | undefined;2497let worktreePath: string | undefined;2498let baseBranchName: string | undefined;2499let baseWorktreePath: string | undefined;25002501try {2502const sessionId = SessionIdForCLI.parse(resource);2503const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2504if (!worktreeProperties || worktreeProperties.version !== 2) {2505vscode.window.showErrorMessage(l10n.t('Merging changes is only supported for worktree-based sessions.'));2506return;2507}25082509branchName = worktreeProperties.branchName;2510baseBranchName = worktreeProperties.baseBranchName;2511} catch (error) {2512logService.error(`Failed to check worktree properties for merge changes: ${error instanceof Error ? error.message : String(error)}`);2513return;2514}25152516const contextValueSegments: string[] = [];2517contextValueSegments.push(`source branch name: ${branchName}`);2518contextValueSegments.push(`source worktree path: ${worktreePath}`);2519contextValueSegments.push(`target branch name: ${baseBranchName}`);2520contextValueSegments.push(`target worktree path: ${baseWorktreePath}`);25212522const prompt = syncWithRemote2523? `${builtinSlashSCommands.merge} and ${builtinSlashSCommands.sync}`2524: builtinSlashSCommands.merge;25252526await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2527resource,2528prompt,2529attachedContext: [{2530id: 'git-merge-changes',2531value: contextValueSegments.join('\n'),2532icon: new vscode.ThemeIcon('git-merge'),2533fullName: `${branchName} → ${baseBranchName}`,2534kind: 'generic'2535}]2536});2537};25382539disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2540await mergeChanges(sessionItemOrResource);2541}));25422543disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.mergeCopilotCLIAgentSessionChanges.mergeAndSync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2544await mergeChanges(sessionItemOrResource, true);2545}));25462547disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.refreshChanges', async (resource?: vscode.Uri) => {2548if (!resource) {2549return;2550}25512552const sessionId = SessionIdForCLI.parse(resource);2553const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2554const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);25552556if (!worktreeProperties && !workspaceFolder) {2557return;2558}25592560if (worktreeProperties) {2561// Worktree2562await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, {2563...worktreeProperties,2564changes: undefined2565});2566} else if (workspaceFolder) {2567// Workspace2568copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId);2569}25702571copilotcliSessionItemProvider.notifySessionsChange();2572}));25732574disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.initializeRepository', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2575const resource = sessionItemOrResource instanceof vscode.Uri2576? sessionItemOrResource2577: sessionItemOrResource?.resource;25782579if (!resource) {2580return;2581}25822583const sessionId = SessionIdForCLI.parse(resource);2584const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);2585if (!workspaceFolder) {2586return;2587}25882589const repository = await gitService.initRepository(workspaceFolder);2590if (!repository) {2591return;2592}25932594const repositoryProperties = repository.state.HEAD?.name2595? {2596repositoryPath: repository.rootUri.fsPath,2597branchName: repository.state.HEAD.name2598} satisfies RepositoryProperties2599: undefined;26002601await copilotCliWorkspaceSession.trackSessionWorkspaceFolder(sessionId, workspaceFolder.fsPath, repositoryProperties);2602copilotCliWorkspaceSession.clearWorkspaceChanges(sessionId);26032604copilotcliSessionItemProvider.notifySessionsChange();2605}));26062607disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commit', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2608const resource = sessionItemOrResource instanceof vscode.Uri2609? sessionItemOrResource2610: sessionItemOrResource?.resource;26112612if (!resource) {2613return;2614}26152616await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2617resource,2618prompt: builtinSlashSCommands.commit,2619});2620}));26212622disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commitAndSync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2623const resource = sessionItemOrResource instanceof vscode.Uri2624? sessionItemOrResource2625: sessionItemOrResource?.resource;26262627if (!resource) {2628return;2629}26302631await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2632resource,2633prompt: `${builtinSlashSCommands.commit} and ${builtinSlashSCommands.sync}`,2634});2635}));26362637disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.sync', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2638const resource = sessionItemOrResource instanceof vscode.Uri2639? sessionItemOrResource2640: sessionItemOrResource?.resource;26412642if (!resource) {2643return;2644}26452646await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2647resource,2648prompt: builtinSlashSCommands.sync,2649});2650}));26512652disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.discardChanges', async (sessionResource: vscode.Uri, ref: string, ...resources: vscode.Uri[]) => {2653if (!isUri(sessionResource) || !ref || resources.length === 0 || resources.some(r => !isUri(r))) {2654return;2655}26562657const sessionId = SessionIdForCLI.parse(sessionResource);2658const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2659const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId);26602661const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder;2662const repository = repositoryUri ? await gitService.getRepository(repositoryUri) : undefined;2663if (!repository) {2664return;2665}26662667const confirmAction = l10n.t('Discard Changes');2668const message = resources.length === 12669? l10n.t('Are you sure you want to discard the changes in \'{0}\'? This action cannot be undone.', basename(resources[0]))2670: l10n.t('Are you sure you want to discard the changes in these {0} files? This action cannot be undone.', resources.length);26712672const choice = await vscode.window.showWarningMessage(message, { modal: true }, confirmAction);2673if (choice !== confirmAction) {2674return;2675}26762677await gitService.restore(repository.rootUri, resources.map(r => r.fsPath), { ref });2678}));26792680disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2681const resource = sessionItemOrResource instanceof vscode.Uri2682? sessionItemOrResource2683: sessionItemOrResource?.resource;26842685if (!resource) {2686return;2687}26882689try {2690const sessionId = SessionIdForCLI.parse(resource);2691const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2692if (!worktreeProperties || worktreeProperties.version !== 2) {2693vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.'));2694return;2695}2696} catch (error) {2697logService.error(`Failed to check worktree properties for createPR: ${error instanceof Error ? error.message : String(error)}`);2698return;2699}27002701await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2702resource,2703prompt: builtinSlashSCommands.createPr,2704});2705}));27062707disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2708const resource = sessionItemOrResource instanceof vscode.Uri2709? sessionItemOrResource2710: sessionItemOrResource?.resource;27112712if (!resource) {2713return;2714}27152716try {2717const sessionId = SessionIdForCLI.parse(resource);2718const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2719if (!worktreeProperties || worktreeProperties.version !== 2) {2720vscode.window.showErrorMessage(l10n.t('Creating a draft pull request is only supported for worktree-based sessions.'));2721return;2722}2723} catch (error) {2724logService.error(`Failed to check worktree properties for createDraftPR: ${error instanceof Error ? error.message : String(error)}`);2725return;2726}27272728await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2729resource,2730prompt: builtinSlashSCommands.createDraftPr,2731});2732}));27332734disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {2735const resource = sessionItemOrResource instanceof vscode.Uri2736? sessionItemOrResource2737: sessionItemOrResource?.resource;27382739if (!resource) {2740return;2741}27422743let pullRequestUrl: string | undefined = undefined;27442745try {2746const sessionId = SessionIdForCLI.parse(resource);2747const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2748if (!worktreeProperties || worktreeProperties.version !== 2) {2749vscode.window.showErrorMessage(l10n.t('Updating a pull request is only supported for worktree-based sessions.'));2750return;2751}27522753pullRequestUrl = worktreeProperties.pullRequestUrl;2754} catch (error) {2755logService.error(`Failed to check worktree properties for updatePR: ${error instanceof Error ? error.message : String(error)}`);2756return;2757}27582759if (!pullRequestUrl) {2760vscode.window.showErrorMessage(l10n.t('No pull request URL found for this session.'));2761return;2762}27632764await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {2765resource,2766prompt: builtinSlashSCommands.updatePr,2767attachedContext: [{2768id: 'github-pull-request',2769fullName: pullRequestUrl,2770icon: new vscode.ThemeIcon('git-pull-request'),2771value: vscode.Uri.parse(pullRequestUrl),2772kind: 'generic'2773}]2774});2775}));27762777disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => {2778logService.trace(`[commitToWorktree] Command invoked, args: ${JSON.stringify(args, null, 2)}`);2779if (!args?.worktreeUri || !args?.fileUri) {2780logService.debug('[commitToWorktree] Missing worktreeUri or fileUri, aborting');2781return;2782}27832784const worktreeUri = vscode.Uri.from(args.worktreeUri);2785const fileUri = vscode.Uri.from(args.fileUri);2786try {2787const fileName = basename(fileUri);2788await gitService.add(worktreeUri, [fileUri.fsPath]);2789logService.debug(`[commitToWorktree] Committing with message: Update customization: ${fileName}`);2790await gitService.commit(worktreeUri, l10n.t('Update customization: {0}', fileName), { noVerify: true, signCommit: false });2791logService.trace('[commitToWorktree] Commit successful');27922793// Clear the worktree changes cache so getWorktreeChanges() recomputes2794const sessionIds = await copilotcliSessionItemProvider.getAssociatedSessions(worktreeUri);2795await Promise.all(sessionIds.map(async sessionId => {2796const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);2797if (props) {2798await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined });2799} else {2800logService.error('[commitToWorktree] No worktree properties found for session:', sessionId);2801}2802}));28032804logService.trace('[commitToWorktree] Notifying sessions change');2805copilotcliSessionItemProvider.notifySessionsChange();2806} catch (error) {2807const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string };2808const normalizedStdout = stdout.toLowerCase();2809const normalizedStderr = stderr.toLowerCase();2810if (normalizedStdout.includes('nothing to commit') || normalizedStderr.includes('nothing to commit') || gitErrorCode === 'NoLocalChanges' || gitErrorCode === 'NotAGitRepository') {2811logService.debug('[commitToWorktree] Nothing to commit or non-applicable repository state, skipping');2812return;2813}2814logService.error('[commitToWorktree] Error:', error);2815vscode.window.showErrorMessage(l10n.t('Failed to commit: {0}', error instanceof Error ? error.message : String(error)));2816}2817}));28182819disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToRepository', async (args?: { repositoryUri?: vscode.Uri; fileUri?: vscode.Uri }) => {2820logService.trace(`[commitToRepository] Command invoked, args: ${JSON.stringify(args, null, 2)}`);2821if (!args?.repositoryUri || !args?.fileUri) {2822logService.debug('[commitToRepository] Missing repositoryUri or fileUri, aborting');2823return;2824}28252826const repositoryUri = vscode.Uri.from(args.repositoryUri);2827const fileUri = vscode.Uri.from(args.fileUri);2828try {2829const fileName = basename(fileUri);2830await gitService.add(repositoryUri, [fileUri.fsPath]);28312832const message = l10n.t('Update customization: {0}', fileName);2833logService.debug(`[commitToRepository] Committing with message: ${message}`);2834await gitService.commit(repositoryUri, message, { noVerify: true, signCommit: false });2835logService.trace('[commitToRepository] Commit successful');2836} catch (error) {2837const stderr = (error as { stderr?: string })?.stderr ?? '';2838const stdout = (error as { stdout?: string })?.stdout ?? '';2839const gitErrorCode = (error as { gitErrorCode?: string })?.gitErrorCode;28402841// Benign: nothing was staged or no local changes to commit2842if (stderr.includes('nothing to commit') || stdout.includes('nothing to commit') || gitErrorCode === 'NoLocalChanges') {2843logService.debug('[commitToRepository] Nothing to commit, skipping');2844return;2845}28462847// Benign: repository URI doesn't point to a git repo2848if (gitErrorCode === 'NotAGitRepository') {2849logService.debug('[commitToRepository] Not a git repository, skipping');2850return;2851}28522853logService.error('[commitToRepository] Error:', error);2854vscode.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."));2855}2856}));28572858return disposableStore;2859}28602861async function getModelFromPromptFile(models: readonly string[], copilotCLIModels: ICopilotCLIModels): Promise<string | undefined> {2862for (const model of models) {2863let modelId = await copilotCLIModels.resolveModel(model);2864if (modelId) {2865return modelId;2866}2867// Sometimes the models can contain ` (Copilot)` suffix, try stripping that and resolving again.2868if (!model.includes('(')) {2869continue;2870}2871modelId = await copilotCLIModels.resolveModel(model.substring(0, model.indexOf('(')).trim());2872if (modelId) {2873return modelId;2874}2875}2876return undefined;2877}287828792880function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] {2881return mruItems.map((item) => {2882if (item.repository) {2883return toRepositoryOptionItem(item.folder);2884} else {2885return toWorkspaceFolderOptionItem(item.folder, basename(item.folder));2886}2887});28882889}289028912892/**2893* Check if a path exists and is a directory.2894*/2895async function checkPathExists(filePath: vscode.Uri, fileSystemService: IFileSystemService): Promise<boolean> {2896try {2897const stat = await fileSystemService.stat(filePath);2898return stat.type === vscode.FileType.Directory;2899} catch {2900return false;2901}2902}29032904function isUnknownEventTypeError(error: unknown): boolean {2905const message = error instanceof Error ? error.message : String(error);2906return /Unknown event type:/i.test(message);2907}29082909/**2910* Queries the GitHub API to find a pull request whose head branch matches the2911* given worktree branch. This covers cases where the MCP tool failed to report2912* a PR URL, or the user created the PR externally (e.g., via github.com).2913*/2914async function detectPullRequestFromGitHubAPI(2915branchName: string,2916repositoryPath: string,2917gitService: IGitService,2918octoKitService: IOctoKitService,2919logService: ILogService,2920): Promise<{ url: string; state: string } | undefined> {2921const repoContext = await gitService.getRepository(URI.file(repositoryPath));2922if (!repoContext) {2923logService.debug(`[detectPullRequestFromGitHubAPI] No git repository found for path: ${repositoryPath}`);2924return undefined;2925}29262927const repoInfo = getGitHubRepoInfoFromContext(repoContext);2928if (!repoInfo) {2929logService.debug(`[detectPullRequestFromGitHubAPI] Could not extract GitHub repo info from repository at: ${repositoryPath}`);2930return undefined;2931}29322933logService.debug(`[detectPullRequestFromGitHubAPI] Querying GitHub API for PR on ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);29342935const pr = await octoKitService.findPullRequestByHeadBranch(2936repoInfo.id.org,2937repoInfo.id.repo,2938branchName,2939{},2940);29412942if (pr?.url) {2943const prState = derivePullRequestState(pr);2944logService.trace(`[detectPullRequestFromGitHubAPI] Detected pull request via GitHub API: ${pr.url} ${prState}`);2945return { url: pr.url, state: prState };2946}29472948logService.debug(`[detectPullRequestFromGitHubAPI] No PR found for ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);2949return undefined;2950}295129522953