diff --git a/package.json b/package.json index ac2069faf..b4f51cb98 100644 --- a/package.json +++ b/package.json @@ -4540,6 +4540,14 @@ "advanced" ] }, + "github.copilot.chat.cli.aiGenerateBranchNames.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.cli.aiGenerateBranchNames.enabled%", + "tags": [ + "advanced" + ] + }, "github.copilot.chat.cli.forkSessions.enabled": { "type": "boolean", "default": false, diff --git a/package.nls.json b/package.nls.json index fca78b714..a388f9a71 100644 --- a/package.nls.json +++ b/package.nls.json @@ -404,6 +404,7 @@ "github.copilot.config.cli.branchSupport.enabled": "Enable branch support for Copilot CLI.", "github.copilot.config.cli.forkSessions.enabled": "Enable forking sessions in Copilot CLI.", "github.copilot.config.cli.planExitMode.enabled": "Enable Plan Mode exit handling in Copilot CLI.", + "github.copilot.config.cli.aiGenerateBranchNames.enabled": "Enable AI-generated branch names in Copilot CLI.", "github.copilot.config.cli.isolationOption.enabled": "Enable the isolation mode option for Copilot CLI. When enabled, users can choose between Worktree and Workspace modes.", "github.copilot.config.cli.autoCommit.enabled": "Enable automatic commit for Copilot CLI. When enabled, changes made by Copilot CLI will be automatically committed to the repository at the end of each turn.", "github.copilot.config.cli.sessionController.enabled": "Enable the new session controller API for Copilot CLI. Requires VS Code reload.", diff --git a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 74b7e4f4b..aad72134c 100644 --- a/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -472,20 +472,25 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false }); return; } - const actionDescriptions: Record = { - 'autopilot': l10n.t('Auto-approve all tool calls and continue until the task is done'), - 'interactive': l10n.t('Let the agent continue in interactive mode, asking for user input and approval for each action.'), - 'exit_only': l10n.t('Exit plan mode, but do not execute the plan. I will execute the plan myself after reviewing it.'), - 'autopilot_fleet': l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.'), - }; + const actionDescriptions: Record = { + 'autopilot': { label: 'Autopilot', description: l10n.t('Auto-approve all tool calls and continue until the task is done') }, + 'interactive': { label: 'Interactive', description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') }, + 'exit_only': { label: 'Approve and exit', description: l10n.t('Exit planning, but do not execute the plan. I will execute the plan myself.') }, + 'autopilot_fleet': { label: 'Autopilot Fleet', description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') }, + } satisfies Record; const approved = true; - event.data.actions; try { + const planPath = this._sdkSession.getPlanPath(); + const userInputRequest: IQuestion = { - question: l10n.t('Approve this plan?'), + question: planPath ? l10n.t('Approve this plan {0}?', `[Plan.md](${Uri.file(planPath).toString()})`) : l10n.t('Approve this plan?'), header: l10n.t('Approve this plan?'), - options: event.data.actions.map(a => ({ label: (actionDescriptions as Record)[a] ?? a, recommended: a === event.data.recommendedAction })), + options: event.data.actions.map(a => ({ + label: actionDescriptions[a]?.label ?? a, + recommended: a === event.data.recommendedAction, + description: actionDescriptions[a]?.description ?? '', + })), allowFreeformInput: true, }; const answer = await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token); @@ -498,8 +503,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false, feedback: answer.freeText }); } else { let selectedAction: ActionType = answer.selected[0] as ActionType; - Object.entries(actionDescriptions).forEach(([action, description]) => { - if (description === selectedAction) { + Object.entries(actionDescriptions).forEach(([action, item]) => { + if (item.label === selectedAction) { selectedAction = action as ActionType; } }); diff --git a/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 88d04894c..49852ebe8 100644 --- a/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -132,6 +132,7 @@ class MockSdkSession { async getSelectedModel() { return this._selectedModel; } async setSelectedModel(model: string) { this._selectedModel = model; } async getEvents() { return []; } + getPlanPath(): string | null { return null; } } function createWorkspaceService(root: string): IWorkspaceService { diff --git a/src/extension/chatSessions/vscode-node/askUserQuestionHandler.ts b/src/extension/chatSessions/vscode-node/askUserQuestionHandler.ts index 9e12c9db6..9a4774ed5 100644 --- a/src/extension/chatSessions/vscode-node/askUserQuestionHandler.ts +++ b/src/extension/chatSessions/vscode-node/askUserQuestionHandler.ts @@ -46,7 +46,7 @@ export class UserQuestionHandler implements IUserQuestionHandler { // Log all available keys in carouselAnswers for debugging this._logService.trace(`[AskQuestionsTool] Question & answers ${question.question}, Answers object: ${JSON.stringify(carouselAnswers)}`); - const answer = carouselAnswers.answers[question.question]; + const answer = carouselAnswers.answers[question.question] ?? carouselAnswers.answers[question.header]; if (answer === undefined) { return undefined; } else if (answer.freeText) { diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index 3d4b879ed..d5f702f41 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -190,7 +190,10 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const gitService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitService)); const sessionTracker = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)); const terminalIntegration = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLITerminalIntegration)); - const branchNameGenerator = copilotcliAgentInstaService.createInstance(GitBranchNameGenerator); + const aiGeneratedBranchNames = instantiationService.invokeFunction(accessor => + accessor.get(IConfigurationService).getConfig(ConfigKey.Advanced.CLIAIGenerateBranchNames) + ); + const branchNameGenerator = aiGeneratedBranchNames ? copilotcliAgentInstaService.createInstance(GitBranchNameGenerator) : undefined; const copilotcliChatSessionParticipant = this._register(copilotcliAgentInstaService.createInstance( CopilotCLIChatSessionParticipant, @@ -260,6 +263,10 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const gitService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitService)); const gitExtensionService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitExtensionService)); const toolsService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IToolsService)); + const aiGeneratedBranchNames = instantiationService.invokeFunction(accessor => + accessor.get(IConfigurationService).getConfig(ConfigKey.Advanced.CLIAIGenerateBranchNames) + ); + const branchNameGenerator = aiGeneratedBranchNames ? copilotcliAgentInstaService.createInstance(GitBranchNameGenerator) : undefined; const copilotcliChatSessionParticipant = this._register(copilotcliAgentInstaService.createInstance( CopilotCLIChatSessionParticipantV1, @@ -267,6 +274,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib promptResolver, copilotcliSessionItemProvider, cloudSessionProvider, + branchNameGenerator )); const copilotCLISessionService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionService)); const copilotCLIWorktreeManagerService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeService)); diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 8982dc62b..3021d92f6 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -892,7 +892,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { private readonly contentProvider: CopilotCLIChatSessionContentProvider, private readonly promptResolver: CopilotCLIPromptResolver, private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined, - private readonly branchNameGenerator: GitBranchNameGenerator, + private readonly branchNameGenerator: GitBranchNameGenerator | undefined, @IGitService private readonly gitService: IGitService, @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, @ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents, @@ -1043,7 +1043,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { history: [requestTurn], yieldRequested: false, }; - const branchNamePromise = isNewSession && request.prompt ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); + const branchNamePromise = (isNewSession && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); const [model, agent] = await Promise.all([ this.getModelId(request, token), this.getAgent(id, request, token), diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index c5d84c0c3..3e6ae616c 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -6,7 +6,7 @@ import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { ChatExtendedRequestHandler, ChatSessionProviderOptionItem, Uri } from 'vscode'; +import { ChatExtendedRequestHandler, ChatRequestTurn2, ChatSessionProviderOptionItem, Uri } from 'vscode'; import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { INativeEnvService } from '../../../platform/env/common/envService'; @@ -33,6 +33,7 @@ import { StopWatch } from '../../../util/vs/base/common/stopwatch'; import { URI } from '../../../util/vs/base/common/uri'; import { EXTENSION_ID } from '../../common/constants'; import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection'; +import { GitBranchNameGenerator } from '../../prompt/node/gitBranch'; import { IToolsService } from '../../tools/common/toolsService'; import { IChatSessionMetadataStore, RepositoryProperties, StoredModeInstructions } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; @@ -1094,6 +1095,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { private readonly promptResolver: CopilotCLIPromptResolver, private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider, private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined, + private readonly branchNameGenerator: GitBranchNameGenerator | undefined, @IGitService private readonly gitService: IGitService, @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, @ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents, @@ -1302,12 +1304,18 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return {}; } + const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined); + const fakeContext: vscode.ChatContext = { + history: [requestTurn], + yieldRequested: false, + }; + const branchNamePromise = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); const [model, agent] = await Promise.all([ this.getModelId(request, token), this.getAgent(id, request, token), ]); - const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent }, disposables, token); + const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, branchName: branchNamePromise }, disposables, token); const session = sessionResult.session; if (session) { disposables.add(session); @@ -1365,7 +1373,10 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } else { // Construct the full prompt with references to be sent to CLI. const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token); - await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token); + const input = (request.command && (copilotCLICommands as readonly string[]).includes(request.command)) + ? { command: request.command as CopilotCLICommand, prompt } + : { prompt: prompt }; + await session.object.handleRequest(request, input, attachments, model, authInfo, token); await this.commitWorktreeChangesIfNeeded(request, session.object, token); } @@ -1671,13 +1682,13 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: string | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { + private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: string | undefined; agent: SweCustomAgent | undefined; branchName: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { const { resource } = chatSessionContext.chatSessionItem; const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource)); const id = existingSessionId ?? SessionIdForCLI.parse(resource); const isNewSession = chatSessionContext.isUntitled && !existingSessionId; - const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, stream, request.toolInvocationToken, token); + const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, options.branchName, stream, request.toolInvocationToken, token); const workingDirectory = getWorkingDirectory(workspaceInfo); const worktreeProperties = workspaceInfo.worktreeProperties; if (cancelled || token.isCancellationRequested) { @@ -1752,6 +1763,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { private async getOrInitializeWorkingDirectory( chatSessionContext: vscode.ChatSessionContext | undefined, + branchName: Promise, stream: vscode.ChatResponseStream, toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken @@ -1770,7 +1782,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { // Use FolderRepositoryManager to initialize folder/repository with worktree creation const branch = _sessionBranch.get(id); const isolation = _sessionIsolation.get(id) ?? undefined; - folderInfo = await this.folderRepositoryManager.initializeFolderRepository(id, { stream, toolInvocationToken, branch: branch ?? undefined, isolation, folder: undefined }, token); + folderInfo = await this.folderRepositoryManager.initializeFolderRepository(id, { stream, toolInvocationToken, branch: branch ?? undefined, isolation, folder: undefined, newBranch: branchName }, token); } else { // Existing session - use getFolderRepository for resolution with trust check folderInfo = await this.folderRepositoryManager.getFolderRepository(id, { promptForTrust: true, stream }, token); @@ -1821,7 +1833,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { })(); const [{ workspaceInfo, cancelled }, model, agent] = await Promise.all([ - this.getOrInitializeWorkingDirectory(undefined, stream, request.toolInvocationToken, token), + this.getOrInitializeWorkingDirectory(undefined, Promise.resolve(undefined), stream, request.toolInvocationToken, token), this.getModelId(request, token), // prefer model in request, as we're delegating from another session here. this.getAgent(undefined, undefined, token) ]); diff --git a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 9aa15e597..de68771e6 100644 --- a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -410,6 +410,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, new NullCopilotCLIAgents(), @@ -758,6 +759,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, new NullCopilotCLIAgents(), @@ -1904,6 +1906,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, agents, @@ -2036,6 +2039,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, new NullCopilotCLIAgents(), diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index 1d4cfdaf7..ef5be7ccc 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -610,6 +610,7 @@ export namespace ConfigKey { export const UseResponsesApiTruncation = defineAndMigrateSetting('chat.advanced.useResponsesApiTruncation', 'chat.useResponsesApiTruncation', false); export const OmitBaseAgentInstructions = defineAndMigrateSetting('chat.advanced.omitBaseAgentInstructions', 'chat.omitBaseAgentInstructions', false); export const CLIPlanExitModeEnabled = defineSetting('chat.cli.planExitMode.enabled', ConfigType.Simple, false); + export const CLIAIGenerateBranchNames = defineSetting('chat.cli.aiGenerateBranchNames.enabled', ConfigType.Simple, false); export const CLIForkSessionsEnabled = defineSetting('chat.cli.forkSessions.enabled', ConfigType.Simple, false); export const CLIMCPServerEnabled = defineAndMigrateSetting('chat.advanced.cli.mcp.enabled', 'chat.cli.mcp.enabled', true); export const CLIBranchSupport = defineSetting('chat.cli.branchSupport.enabled', ConfigType.Simple, false);