From bfcfe66cac9aaf7c19cdd5e32d69c2684f93f538 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Fri, 7 Feb 2025 12:23:44 -0500 Subject: [PATCH] Adds AI-powered stash message generation support --- CHANGELOG.md | 1 + docs/telemetry-events.md | 17 ++ package.json | 10 ++ src/ai/aiProviderService.ts | 263 +++++++++++++++++++---------- src/ai/openAICompatibleProvider.ts | 22 ++- src/ai/prompts.ts | 43 ++++- src/ai/vscodeProvider.ts | 37 +++- src/commands/git/stash.ts | 70 +++++++- src/commands/quickCommand.ts | 39 ++++- src/commands/quickWizard.base.ts | 8 +- src/config.ts | 3 + src/constants.telemetry.ts | 6 +- src/plus/launchpad/launchpad.ts | 4 +- src/plus/startWork/startWork.ts | 4 +- src/system/function.ts | 8 +- src/system/unifiedDisposable.ts | 19 ++- 16 files changed, 426 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf35b34edaff..3da16c5758bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- Adds new AI-powered ability to generate a stash message from the changes in the _Stash_ commands - Adds and expands AI model support for GitLens' AI features - Adds DeepSeek V3 and R1 models — closes [#3943](https://github.com/gitkraken/vscode-gitlens/issues/3943) - Adds o3-mini and o1 OpenAI models diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index a8feb50aaf0ea..f385c283666fc 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -160,6 +160,23 @@ or } ``` +or + +```typescript +{ + 'duration': number, + 'failed.error': string, + 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', + 'input.length': number, + 'model.id': string, + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'huggingface' | 'openai' | 'vscode' | 'xai', + 'model.provider.name': string, + 'output.length': number, + 'retry.count': number, + 'type': 'stashMessage' +} +``` + ### associateIssueWithBranch/action > Sent when the user chooses to manage integrations diff --git a/package.json b/package.json index cff13612efb8c..954d9de2915ca 100644 --- a/package.json +++ b/package.json @@ -3939,6 +3939,16 @@ "preview" ] }, + "gitlens.ai.generateStashMessage.customInstructions": { + "type": "string", + "default": null, + "markdownDescription": "Specifies custom instructions to provide to the AI provider when generating a stash message", + "scope": "window", + "order": 220, + "tags": [ + "preview" + ] + }, "gitlens.ai.generateCloudPatchMessage.customInstructions": { "type": "string", "default": null, diff --git a/src/ai/aiProviderService.ts b/src/ai/aiProviderService.ts index 5088649ffbfeb..08dfadcb22e89 100644 --- a/src/ai/aiProviderService.ts +++ b/src/ai/aiProviderService.ts @@ -8,7 +8,6 @@ import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; import type { GitRevisionReference } from '../git/models/reference'; import type { Repository } from '../git/models/repository'; -import { isRepository } from '../git/models/repository'; import { uncommitted, uncommittedStaged } from '../git/models/revision'; import { assertsCommitHasFullDetails } from '../git/utils/commit.utils'; import { showAIModelPicker } from '../quickpicks/aiModelPicker'; @@ -16,6 +15,7 @@ import { configuration } from '../system/-webview/configuration'; import type { Storage } from '../system/-webview/storage'; import { supportedInVSCodeVersion } from '../system/-webview/vscode'; import { formatNumeric } from '../system/date'; +import type { Deferred } from '../system/promise'; import { getSettledValue } from '../system/promise'; import { getPossessiveForm } from '../system/string'; import type { TelemetryService } from '../telemetry/telemetry'; @@ -83,6 +83,12 @@ export interface AIProvider extends reporting: TelemetryEvents['ai/generate'], options?: { cancellation?: CancellationToken; context?: string }, ): Promise; + generateStashMessage( + model: AIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise; generateDraftMessage( model: AIModel, diff: string, @@ -132,7 +138,7 @@ export class AIProviderService implements Disposable { return models.flatMap(m => getSettledValue(m, [])); } - private async getModel(options?: { force?: boolean; silent?: boolean }): Promise { + async getModel(options?: { force?: boolean; silent?: boolean }): Promise { const cfg = this.getConfiguredModel(); if (!options?.force && cfg?.provider != null && cfg?.model != null) { const model = await this.getOrUpdateModel(cfg.provider, cfg.model); @@ -213,32 +219,23 @@ export class AIProviderService implements Disposable { } async generateCommitMessage( - changes: string[], - sourceContext: { source: Sources }, - options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, - ): Promise; - async generateCommitMessage( - repoPath: Uri, - sourceContext: { source: Sources }, - options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, - ): Promise; - async generateCommitMessage( - repository: Repository, - sourceContext: { source: Sources }, - options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, - ): Promise; - async generateCommitMessage( - changesOrRepoOrPath: string[] | Repository | Uri, + changesOrRepo: string | string[] | Repository, sourceContext: { source: Sources }, - options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + options?: { + cancellation?: CancellationToken; + context?: string; + generating?: Deferred; + progress?: ProgressOptions; + }, ): Promise { - const changes: string | undefined = await this.getChanges(changesOrRepoOrPath); + const changes: string | undefined = await this.getChanges(changesOrRepo); if (changes == null) return undefined; - const model = await this.getModel(); - if (model == null) return undefined; - - const provider = this._provider!; + const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage); + if (model == null) { + options?.generating?.cancel(); + return undefined; + } const payload: TelemetryEvents['ai/generate'] = { type: 'commitMessage', @@ -249,10 +246,10 @@ export class AIProviderService implements Disposable { }; const source: Parameters[2] = { source: sourceContext.source }; - const confirmed = await confirmAIProviderToS(this, model, this.container.storage); if (!confirmed) { this.container.telemetry.sendEvent('ai/generate', { ...payload, 'failed.reason': 'user-declined' }, source); + options?.generating?.cancel(); return undefined; } @@ -263,13 +260,15 @@ export class AIProviderService implements Disposable { source, ); + options?.generating?.cancel(); return undefined; } - const promise = provider.generateCommitMessage(model, changes, payload, { + const promise = this._provider!.generateCommitMessage(model, changes, payload, { cancellation: options?.cancellation, context: options?.context, }); + options?.generating?.fulfill(model); const start = Date.now(); try { @@ -303,22 +302,24 @@ export class AIProviderService implements Disposable { } async generateDraftMessage( - changesOrRepoOrPath: string[] | Repository | Uri, + changesOrRepo: string | string[] | Repository, sourceContext: { source: Sources; type: AIGenerateDraftEventData['draftType'] }, options?: { cancellation?: CancellationToken; context?: string; + generating?: Deferred; progress?: ProgressOptions; codeSuggestion?: boolean; }, ): Promise { - const changes: string | undefined = await this.getChanges(changesOrRepoOrPath); + const changes: string | undefined = await this.getChanges(changesOrRepo); if (changes == null) return undefined; - const model = await this.getModel(); - if (model == null) return undefined; - - const provider = this._provider!; + const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage); + if (model == null) { + options?.generating?.cancel(); + return undefined; + } const payload: TelemetryEvents['ai/generate'] = { type: 'draftMessage', @@ -330,10 +331,10 @@ export class AIProviderService implements Disposable { }; const source: Parameters[2] = { source: sourceContext.source }; - const confirmed = await confirmAIProviderToS(this, model, this.container.storage); if (!confirmed) { this.container.telemetry.sendEvent('ai/generate', { ...payload, 'failed.reason': 'user-declined' }, source); + options?.generating?.cancel(); return undefined; } @@ -344,14 +345,16 @@ export class AIProviderService implements Disposable { source, ); + options?.generating?.cancel(); return undefined; } - const promise = provider.generateDraftMessage(model, changes, payload, { + const promise = this._provider!.generateDraftMessage(model, changes, payload, { cancellation: options?.cancellation, context: options?.context, codeSuggestion: options?.codeSuggestion, }); + options?.generating?.fulfill(model); const start = Date.now(); try { @@ -381,22 +384,105 @@ export class AIProviderService implements Disposable { } } + async generateStashMessage( + changesOrRepo: string | string[] | Repository, + sourceContext: { source: Sources }, + options?: { + cancellation?: CancellationToken; + context?: string; + generating?: Deferred; + progress?: ProgressOptions; + }, + ): Promise { + const changes: string | undefined = await this.getChanges(changesOrRepo); + if (changes == null) { + options?.generating?.cancel(); + return undefined; + } + + const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage); + if (model == null) { + options?.generating?.cancel(); + return undefined; + } + + const payload: TelemetryEvents['ai/generate'] = { + type: 'stashMessage', + 'model.id': model.id, + 'model.provider.id': model.provider.id, + 'model.provider.name': model.provider.name, + 'retry.count': 0, + }; + const source: Parameters[2] = { source: sourceContext.source }; + + if (!confirmed) { + this.container.telemetry.sendEvent('ai/generate', { ...payload, 'failed.reason': 'user-declined' }, source); + + options?.generating?.cancel(); + return undefined; + } + + if (options?.cancellation?.isCancellationRequested) { + this.container.telemetry.sendEvent( + 'ai/generate', + { ...payload, 'failed.reason': 'user-cancelled' }, + source, + ); + + options?.generating?.cancel(); + return undefined; + } + + const promise = this._provider!.generateStashMessage(model, changes, payload, { + cancellation: options?.cancellation, + context: options?.context, + }); + options?.generating?.fulfill(model); + + const start = Date.now(); + try { + const result = await (options?.progress != null + ? window.withProgress( + { ...options.progress, title: `Generating stash message with ${model.name}...` }, + () => promise, + ) + : promise); + + payload['output.length'] = result?.length; + this.container.telemetry.sendEvent('ai/generate', { ...payload, duration: Date.now() - start }, source); + + if (result == null) return undefined; + return parseResult(result); + } catch (ex) { + this.container.telemetry.sendEvent( + 'ai/generate', + { + ...payload, + duration: Date.now() - start, + ...(ex instanceof CancellationError + ? { 'failed.reason': 'user-cancelled' } + : { 'failed.reason': 'error', 'failed.error': String(ex) }), + }, + source, + ); + + throw ex; + } + } + private async getChanges( - changesOrRepoOrPath: string[] | Repository | Uri, + changesOrRepo: string | string[] | Repository, options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, ): Promise { let changes: string; - if (Array.isArray(changesOrRepoOrPath)) { - changes = changesOrRepoOrPath.join('\n'); + if (typeof changesOrRepo === 'string') { + changes = changesOrRepo; + } else if (Array.isArray(changesOrRepo)) { + changes = changesOrRepo.join('\n'); } else { - const repository = isRepository(changesOrRepoOrPath) - ? changesOrRepoOrPath - : this.container.git.getRepository(changesOrRepoOrPath); - if (repository == null) throw new Error('Unable to find repository'); - - let diff = await this.container.git.getDiff(repository.uri, uncommittedStaged); + let diff = await this.container.git.getDiff(changesOrRepo.uri, uncommittedStaged); if (!diff?.contents) { - diff = await this.container.git.getDiff(repository.uri, uncommitted); + diff = await this.container.git.getDiff(changesOrRepo.uri, uncommitted); if (!diff?.contents) throw new Error('No changes to generate a commit message from.'); } if (options?.cancellation?.isCancellationRequested) return undefined; @@ -415,11 +501,9 @@ export class AIProviderService implements Disposable { const diff = await this.container.git.getDiff(commitOrRevision.repoPath, commitOrRevision.ref); if (!diff?.contents) throw new Error('No changes found to explain.'); - const model = await this.getModel(); + const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage); if (model == null) return undefined; - const provider = this._provider!; - const payload: TelemetryEvents['ai/explain'] = { type: 'change', changeType: sourceContext.type, @@ -430,7 +514,6 @@ export class AIProviderService implements Disposable { }; const source: Parameters[2] = { source: sourceContext.source }; - const confirmed = await confirmAIProviderToS(this, model, this.container.storage); if (!confirmed) { this.container.telemetry.sendEvent('ai/explain', { ...payload, 'failed.reason': 'user-declined' }, source); @@ -453,7 +536,7 @@ export class AIProviderService implements Disposable { return undefined; } - const promise = provider.explainChanges(model, commit.message, diff.contents, payload, { + const promise = this._provider!.explainChanges(model, commit.message, diff.contents, payload, { cancellation: options?.cancellation, }); @@ -543,55 +626,59 @@ export class AIProviderService implements Disposable { return _supportedProviderTypes.has(provider as AIProviders); } - async switchModel(): Promise { - void (await this.getModel({ force: true })); + switchModel(): Promise { + return this.getModel({ force: true }); } } -async function confirmAIProviderToS( +async function getModelAndConfirmAIProviderToS( service: AIProviderService, - model: AIModel, storage: Storage, -): Promise { - const confirmed = - storage.get(`confirm:ai:tos:${model.provider.id}`, false) || - storage.getWorkspace(`confirm:ai:tos:${model.provider.id}`, false); - if (confirmed) return true; - - const accept: MessageItem = { title: 'Continue' }; - const switchModel: MessageItem = { title: 'Switch Model' }; - const acceptWorkspace: MessageItem = { title: 'Always for this Workspace' }; - const acceptAlways: MessageItem = { title: 'Always' }; - const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true }; - - const result = await window.showInformationMessage( - `GitLens AI features require sending a diff of the code changes to ${model.provider.name} for analysis. This may contain sensitive information.\n\nDo you want to continue?`, - { modal: true }, - accept, - switchModel, - acceptWorkspace, - acceptAlways, - decline, - ); +): Promise<{ confirmed: boolean; model: AIModel | undefined }> { + let model = await service.getModel(); + while (true) { + if (model == null) return { confirmed: false, model: model }; + + const confirmed = + storage.get(`confirm:ai:tos:${model.provider.id}`, false) || + storage.getWorkspace(`confirm:ai:tos:${model.provider.id}`, false); + if (confirmed) return { confirmed: true, model: model }; + + const accept: MessageItem = { title: 'Continue' }; + const switchModel: MessageItem = { title: 'Switch Model' }; + const acceptWorkspace: MessageItem = { title: 'Always for this Workspace' }; + const acceptAlways: MessageItem = { title: 'Always' }; + const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + + const result = await window.showInformationMessage( + `GitLens AI features require sending a diff of the code changes to ${model.provider.name} for analysis. This may contain sensitive information.\n\nDo you want to continue?`, + { modal: true }, + accept, + switchModel, + acceptWorkspace, + acceptAlways, + decline, + ); + + if (result === switchModel) { + model = await service.switchModel(); + continue; + } - if (result === accept) return true; + if (result === accept) return { confirmed: true, model: model }; - if (result === switchModel) { - void service.switchModel(); - return false; - } + if (result === acceptWorkspace) { + void storage.storeWorkspace(`confirm:ai:tos:${model.provider.id}`, true).catch(); + return { confirmed: true, model: model }; + } - if (result === acceptWorkspace) { - void storage.storeWorkspace(`confirm:ai:tos:${model.provider.id}`, true).catch(); - return true; - } + if (result === acceptAlways) { + void storage.store(`confirm:ai:tos:${model.provider.id}`, true).catch(); + return { confirmed: true, model: model }; + } - if (result === acceptAlways) { - void storage.store(`confirm:ai:tos:${model.provider.id}`, true).catch(); - return true; + return { confirmed: false, model: model }; } - - return false; } export function getMaxCharacters(model: AIModel, outputLength: number, overrideInputTokens?: number): number { diff --git a/src/ai/openAICompatibleProvider.ts b/src/ai/openAICompatibleProvider.ts index a06b2013910fd..11bbb978a17f9 100644 --- a/src/ai/openAICompatibleProvider.ts +++ b/src/ai/openAICompatibleProvider.ts @@ -20,6 +20,7 @@ import { generateCloudPatchMessageUserPrompt, generateCodeSuggestMessageUserPrompt, generateCommitMessageUserPrompt, + generateStashMessageUserPrompt, } from './prompts'; export interface AIProviderConfig { @@ -64,7 +65,7 @@ export abstract class OpenAICompatibleProvider implements diff: string, reporting: TelemetryEvents['ai/generate'], promptConfig: { - type: 'commit' | 'cloud-patch' | 'code-suggestion'; + type: 'commit' | 'cloud-patch' | 'code-suggestion' | 'stash'; userPrompt: string; customInstructions?: string; }, @@ -161,6 +162,25 @@ export abstract class OpenAICompatibleProvider implements ); } + async generateStashMessage( + model: AIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + return this.generateMessage( + model, + diff, + reporting, + { + type: 'stash', + userPrompt: generateStashMessageUserPrompt, + customInstructions: configuration.get('ai.generateStashMessage.customInstructions'), + }, + options, + ); + } + async explainChanges( model: AIModel, message: string, diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index ae749a3c113dc..77de565bded8c 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -32,9 +32,8 @@ To create an effective commit message, follow these steps: - Focus on the "why" rather than the "what" of the changes. 5. If the changes are related to a specific issue or ticket, include the reference (e.g., "Fixes #123" or "Relates to JIRA-456") at the end of the commit message. -Don't over explain and write your commit message summary inside tags and your commit message body inside tags and include no other text. +Don't over explain and write your commit message summary inside tags and your commit message body inside tags and include no other text: -Example format: Implements user authentication feature @@ -48,7 +47,35 @@ Fixes #789 \${instructions} -Now, based on the provided code diff and any additional context, create a concise but meaningful commit message following the instructions above.`; +Based on the provided code diff and any additional context, create a concise but meaningful commit message following the instructions above.`; + +export const generateStashMessageUserPrompt = `You are an advanced AI programming assistant and are tasked with creating a concise but descriptive stash message. You will be provided with a code diff of uncommitted changes. Your goal is to analyze the changes and create a clear, single-line stash message that accurately represents the work in progress being stashed. + +First, examine the following code changes provided in Git diff format: +<~~diff~~> +\${diff} + + +To create an effective stash message, follow these steps: + +1. Analyze the changes and focus on: + - The primary feature or bug fix was being worked on + - The overall intent of the changes + - Any notable file or areas being modified +2. Create a single-line message that: + - Briefly describes the changes being stashed but must be descriptive enough to identify later + - Prioritizes the most significant change if multiple changes are present. If multiple related changes are significant, try to summarize them concisely + - Use a future-oriented manner, third-person singular present tense (e.g., 'Fixes', 'Updates', 'Improves', 'Adds', 'Removes') + +Write your stash message inside tags and include no other text: + + +Adds new awesome feature + + +\${instructions} + +Based on the provided code diff, create a concise but descriptive stash message following the instructions above.`; export const generateCloudPatchMessageUserPrompt = `You are an advanced AI programming assistant and are tasked with summarizing code changes into a concise and meaningful title and description. You will be provided with a code diff and optional additional context. Your goal is to analyze the changes and create a clear, informative title and description that accurately represents the modifications made to the code. @@ -81,9 +108,8 @@ To create an effective title and description, follow these steps: - Add line breaks for readability and to separate independent ideas - Focus on the "why" rather than the "what" of the changes. -Write your title inside tags and your description inside tags and include no other text. +Write your title inside tags and your description inside tags and include no other text: -Example format: Implements user authentication feature @@ -95,7 +121,7 @@ Integrates JWT for secure token generation \${instructions} -Now, based on the provided code diff and any additional context, create a concise but meaningful title and description following the instructions above.`; +Based on the provided code diff and any additional context, create a concise but meaningful title and description following the instructions above.`; export const generateCodeSuggestMessageUserPrompt = `You are an advanced AI programming assistant and are tasked with summarizing code changes into a concise and meaningful code review title and description. You will be provided with a code diff and optional additional context. Your goal is to analyze the changes and create a clear, informative code review title and description that accurately represents the modifications made to the code. @@ -128,9 +154,8 @@ To create an effective title and description, follow these steps: - Add line breaks for readability and to separate independent ideas - Focus on the "why" rather than the "what" of the changes. -Write your title inside tags and your description inside tags and include no other text. +Write your title inside tags and your description inside tags and include no other text: -Example format: Implements user authentication feature @@ -142,7 +167,7 @@ Integrates JWT for secure token generation \${instructions} -Now, based on the provided code diff and any additional context, create a concise but meaningful code review title and description following the instructions above.`; +Based on the provided code diff and any additional context, create a concise but meaningful code review title and description following the instructions above.`; export const explainChangesUserPrompt = `You are an advanced AI programming assistant and are tasked with creating clear, technical summaries of code changes that help reviewers understand the modifications and their implications. You will analyze a code diff and the author-provided message to produce a structured summary that captures the essential aspects of the changes. diff --git a/src/ai/vscodeProvider.ts b/src/ai/vscodeProvider.ts index 1047b1436b380..65edc17cc65f8 100644 --- a/src/ai/vscodeProvider.ts +++ b/src/ai/vscodeProvider.ts @@ -5,7 +5,7 @@ import type { TelemetryEvents } from '../constants.telemetry'; import type { Container } from '../container'; import { configuration } from '../system/-webview/configuration'; import { sum } from '../system/iterable'; -import { capitalize, getPossessiveForm, interpolate } from '../system/string'; +import { capitalize, interpolate } from '../system/string'; import type { AIModel, AIProvider } from './aiProviderService'; import { getMaxCharacters, getValidatedTemperature, showDiffTruncationWarning } from './aiProviderService'; import { @@ -13,6 +13,7 @@ import { generateCloudPatchMessageUserPrompt, generateCodeSuggestMessageUserPrompt, generateCommitMessageUserPrompt, + generateStashMessageUserPrompt, } from './prompts'; const provider = { id: 'vscode', name: 'VS Code Provided' } as const; @@ -53,7 +54,7 @@ export class VSCodeAIProvider implements AIProvider { diff: string, reporting: TelemetryEvents['ai/generate'], promptConfig: { - type: 'commit' | 'cloud-patch' | 'code-suggestion'; + type: 'commit' | 'cloud-patch' | 'code-suggestion' | 'stash'; userPrompt: string; customInstructions?: string; }, @@ -114,6 +115,10 @@ export class VSCodeAIProvider implements AIProvider { let message = ex instanceof Error ? ex.message : String(ex); + if (ex instanceof Error && 'code' in ex && ex.code === 'NoPermissions') { + throw new Error(`User denied access to ${model.provider.name}`); + } + if (ex instanceof Error && 'cause' in ex && ex.cause instanceof Error) { message += `\n${ex.cause.message}`; @@ -124,8 +129,8 @@ export class VSCodeAIProvider implements AIProvider { } throw new Error( - `Unable to generate ${promptConfig.type} message: (${getPossessiveForm(model.provider.name)}:${ - ex.code + `Unable to generate ${promptConfig.type} message: (${model.provider.name}${ + ex.code ? `:${ex.code}` : '' }) ${message}`, ); } @@ -191,6 +196,28 @@ export class VSCodeAIProvider implements AIProvider { ); } + async generateStashMessage( + model: VSCodeAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + }, + ): Promise { + return this.generateMessage( + model, + diff, + reporting, + { + type: 'stash', + userPrompt: generateStashMessageUserPrompt, + customInstructions: configuration.get('ai.generateStashMessage.customInstructions'), + }, + options, + ); + } + async explainChanges( model: VSCodeAIModel, message: string, @@ -268,7 +295,7 @@ export class VSCodeAIProvider implements AIProvider { } throw new Error( - `Unable to explain changes: (${getPossessiveForm(model.provider.name)}:${ex.code}) ${message}`, + `Unable to explain changes: (${model.provider.name}${ex.code ? `:${ex.code}` : ''}) ${message}`, ); } } diff --git a/src/commands/git/stash.ts b/src/commands/git/stash.ts index 61c53871e752f..3a1668805b71f 100644 --- a/src/commands/git/stash.ts +++ b/src/commands/git/stash.ts @@ -1,5 +1,6 @@ -import type { QuickPickItem, Uri } from 'vscode'; -import { QuickInputButtons, window } from 'vscode'; +import type { QuickInputButton, QuickPickItem, Uri } from 'vscode'; +import { InputBoxValidationSeverity, QuickInputButtons, ThemeIcon, window } from 'vscode'; +import type { AIModel } from '../../ai/aiProviderService'; import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; import { reveal, showDetailsView } from '../../git/actions/stash'; @@ -7,6 +8,7 @@ import { StashApplyError, StashApplyErrorReason, StashPushError, StashPushErrorR import type { GitStashCommit } from '../../git/models/commit'; import type { GitStashReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; +import { uncommitted, uncommittedStaged } from '../../git/models/revision'; import { getReferenceLabel } from '../../git/utils/reference.utils'; import { showGenericErrorMessage } from '../../messages'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; @@ -14,7 +16,9 @@ import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; import { getContext } from '../../system/-webview/context'; import { formatPath } from '../../system/-webview/formatPath'; -import { Logger } from '../../system/logger'; +import { getLoggableName, Logger } from '../../system/logger'; +import { startLogScope } from '../../system/logger.scope'; +import { defer } from '../../system/promise'; import { pad } from '../../system/string'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { @@ -609,6 +613,13 @@ export class StashGitCommand extends QuickCommand { state: PushStepState, context: Context, ): AsyncStepResultGenerator { + using scope = startLogScope(`${getLoggableName(this)}.pushCommandInputMessageStep`, false); + + const generateMessageButton: QuickInputButton = { + iconPath: new ThemeIcon('sparkle'), + tooltip: 'Generate Stash Message', + }; + const step = createInputStep({ title: appendReposToTitle( context.title, @@ -625,13 +636,62 @@ export class StashGitCommand extends QuickCommand { placeholder: 'Please provide a stash message', value: state.message, prompt: 'Enter stash message', - }); + buttons: [QuickInputButtons.Back, generateMessageButton], + onDidClickButton: async (input, button) => { + if (button === generateMessageButton) { + using resume = step.freeze?.(); + + try { + const diff = await state.repo.git.getDiff( + state.flags.includes('--staged') ? uncommittedStaged : uncommitted, + undefined, + state.uris?.length ? { uris: state.uris } : undefined, + ); + if (!diff?.contents) { + void window.showInformationMessage('No changes to generate a stash message from.'); + } + + const generating = defer(); + generating.promise.then( + m => { + input.validationMessage = { + severity: InputBoxValidationSeverity.Info, + message: `$(loading~spin) Generating stash message with ${m.name}...`, + }; + resume?.dispose(); + }, + () => { + input.validationMessage = undefined; + resume?.dispose(); + }, + ); + + const result = await ( + await this.container.ai + )?.generateStashMessage(diff!.contents, { source: 'quick-wizard' }, { generating: generating }); + input.validationMessage = undefined; + + const message = result?.summary; + if (message != null) { + state.message = message; + input.value = message; + } + } catch (ex) { + Logger.error(ex, scope, 'generateStashMessage'); + if (ex instanceof Error && ex.message.startsWith('No changes')) { + void window.showInformationMessage('No changes to generate a stash message from.'); + } else { + void showGenericErrorMessage(ex.message); + } + } + } + }, + }); const value: StepSelection = yield step; if (!canStepContinue(step, state, value) || !(await canInputStepContinue(step, state, value))) { return StepResultBreak; } - return value; } diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts index e19d755e84abd..f1d0d51bccbe4 100644 --- a/src/commands/quickCommand.ts +++ b/src/commands/quickCommand.ts @@ -1,4 +1,4 @@ -import type { InputBox, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; +import type { InputBox, QuickInput, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; import type { Keys } from '../constants'; import type { GlCommands } from '../constants.commands'; import type { Container } from '../container'; @@ -6,6 +6,8 @@ import { createQuickPickSeparator } from '../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; import { createDirectiveQuickPickItem, Directive, isDirective } from '../quickpicks/items/directive'; import { configuration } from '../system/-webview/configuration'; +import type { UnifiedDisposable } from '../system/unifiedDisposable'; +import { createDisposable } from '../system/unifiedDisposable'; export interface CustomStep { type: 'custom'; @@ -35,6 +37,11 @@ export interface QuickInputStep { title?: string; value?: T; + input?: QuickInput; + freeze?: () => UnifiedDisposable; + frozen?: boolean; + + onDidActivate?(input: QuickInput): void; onDidClickButton?(input: InputBox, button: QuickInputButton): boolean | void | Promise; onDidPressKey?(quickpick: InputBox, key: Keys): void | Promise; validate?(value: T | undefined): [boolean, T | undefined] | Promise<[boolean, T | undefined]>; @@ -67,7 +74,7 @@ export interface QuickPickStep { selectValueWhenShown?: boolean; quickpick?: QuickPick; - freeze?: () => Disposable; + freeze?: () => UnifiedDisposable; frozen?: boolean; onDidActivate?(quickpick: QuickPick): void; @@ -358,8 +365,27 @@ export function createConfirmStep(step: Optional, 'type'>): QuickInputStep { + const original = step.onDidActivate; // Make sure any input steps won't close on focus loss - return { type: 'input', ...step, ignoreFocusOut: true }; + step = { type: 'input' as const, ...step, ignoreFocusOut: true }; + step.onDidActivate = input => { + step.input = input; + step.freeze = () => { + input.enabled = false; + step.frozen = true; + return createDisposable( + () => { + step.frozen = false; + input.enabled = true; + input.show(); + }, + { once: true }, + ); + }; + original?.(input); + }; + + return step as QuickInputStep; } export function createPickStep(step: Optional, 'type'>): QuickPickStep { @@ -372,14 +398,15 @@ export function createPickStep(step: Optional { + return createDisposable( + () => { step.frozen = false; qp.enabled = true; qp.ignoreFocusOut = originalFocusOut; qp.show(); }, - }; + { once: true }, + ); }; original?.(qp); }; diff --git a/src/commands/quickWizard.base.ts b/src/commands/quickWizard.base.ts index 884773155531a..6f927121969a5 100644 --- a/src/commands/quickWizard.base.ts +++ b/src/commands/quickWizard.base.ts @@ -295,7 +295,11 @@ export abstract class QuickWizardCommandBase extends GlCommandBase { disposables.push( scope, - input.onDidHide(() => resolve(undefined)), + input.onDidHide(() => { + if (step.frozen) return; + + resolve(undefined); + }), input.onDidTriggerButton(async e => { if (e === QuickInputButtons.Back) { void goBack(); @@ -379,6 +383,8 @@ export abstract class QuickWizardCommandBase extends GlCommandBase { debugger; } } + + step.onDidActivate?.(input); }); } finally { input.dispose(); diff --git a/src/config.ts b/src/config.ts index 9073aea23eb82..87794d80edb1a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -215,6 +215,9 @@ interface AIConfig { readonly customInstructions: string; readonly enabled: boolean; }; + readonly generateStashMessage: { + readonly customInstructions: string; + }; readonly generateCloudPatchMessage: { readonly customInstructions: string; }; diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index cb7ac5eb799ea..8b9f90ce3d1f9 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -322,7 +322,11 @@ export interface AIGenerateDraftEventData extends AIEventDataBase { draftType: 'patch' | 'stash' | 'suggested_pr_change'; } -type AIGenerateEvent = AIGenerateCommitEventData | AIGenerateDraftEventData; +export interface AIGenerateStashEventData extends AIEventDataBase { + type: 'stashMessage'; +} + +type AIGenerateEvent = AIGenerateCommitEventData | AIGenerateDraftEventData | AIGenerateStashEventData; interface CloudIntegrationsConnectingEvent { 'integration.ids': string | undefined; diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 470f0bbb20d90..03e2805457f71 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -1235,7 +1235,7 @@ export class LaunchpadCommand extends QuickCommand { const resume = step.freeze?.(); const chosenIntegrationId = selection[0].item; const connected = await this.ensureIntegrationConnected(chosenIntegrationId); - return { connected: connected ? chosenIntegrationId : false, resume: () => resume?.[Symbol.dispose]() }; + return { connected: connected ? chosenIntegrationId : false, resume: () => resume?.dispose() }; } return StepResultBreak; @@ -1304,7 +1304,7 @@ export class LaunchpadCommand extends QuickCommand { if (step.quickpick) { step.quickpick.placeholder = previousPlaceholder; } - return { connected: connected, resume: () => resume?.[Symbol.dispose]() }; + return { connected: connected, resume: () => resume?.dispose() }; } return StepResultBreak; diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts index a1b3b33974ff8..85292898567af 100644 --- a/src/plus/startWork/startWork.ts +++ b/src/plus/startWork/startWork.ts @@ -310,7 +310,7 @@ export abstract class StartWorkBaseCommand extends QuickCommand { const resume = step.freeze?.(); const chosenIntegrationId = selection[0].item; const connected = await this.ensureIntegrationConnected(chosenIntegrationId); - return { connected: connected ? chosenIntegrationId : false, resume: () => resume?.[Symbol.dispose]() }; + return { connected: connected ? chosenIntegrationId : false, resume: () => resume?.dispose() }; } return StepResultBreak; @@ -389,7 +389,7 @@ export abstract class StartWorkBaseCommand extends QuickCommand { if (step.quickpick) { step.quickpick.placeholder = previousPlaceholder; } - return { connected: connected, resume: () => resume?.[Symbol.dispose]() }; + return { connected: connected, resume: () => resume?.dispose() }; } return StepResultBreak; diff --git a/src/system/function.ts b/src/system/function.ts index b611634be5b93..dd99dd6249d4f 100644 --- a/src/system/function.ts +++ b/src/system/function.ts @@ -155,17 +155,17 @@ export function is(o: object, propOrMatcher?: keyof T | ((o: a return value === undefined ? (o as any)[propOrMatcher] !== undefined : (o as any)[propOrMatcher] === value; } -export function once any>(fn: T): T { +export function once unknown>(fn: T): T { let result: ReturnType; let called = false; - return function (this: any, ...args: Parameters): ReturnType { + return function (this: unknown, ...args: Parameters): ReturnType { if (!called) { called = true; - result = fn.apply(this, args); + result = fn.apply(this, args) as ReturnType; fn = undefined!; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result; } as T; } diff --git a/src/system/unifiedDisposable.ts b/src/system/unifiedDisposable.ts index 76b8f484b91e2..b333a145b959d 100644 --- a/src/system/unifiedDisposable.ts +++ b/src/system/unifiedDisposable.ts @@ -1,16 +1,27 @@ -import type { Disposable as CoreDisposable } from 'vscode'; +import { once } from './function'; -export type UnifiedDisposable = Disposable & CoreDisposable; +export type UnifiedDisposable = { dispose: () => void } & Disposable; export type UnifiedAsyncDisposable = { dispose: () => Promise } & AsyncDisposable; -export function createDisposable(dispose: () => void): UnifiedDisposable { +export function createDisposable(dispose: () => void, options?: { once?: boolean }): UnifiedDisposable { + if (options?.once) { + dispose = once(dispose); + } + return { dispose: dispose, [Symbol.dispose]: dispose, }; } -export function createAsyncDisposable(dispose: () => Promise): UnifiedAsyncDisposable { +export function createAsyncDisposable( + dispose: () => Promise, + options?: { once?: boolean }, +): UnifiedAsyncDisposable { + if (options?.once) { + dispose = once(dispose); + } + return { dispose: dispose, [Symbol.asyncDispose]: dispose,