diff --git a/extensions/positron-assistant/package.json b/extensions/positron-assistant/package.json index f614d9a6aa75..861fe4958bd1 100644 --- a/extensions/positron-assistant/package.json +++ b/extensions/positron-assistant/package.json @@ -104,6 +104,20 @@ ] } ], + "menus": { + "scm/inputBox": [ + { + "command": "positron-assistant.generateCommitMessage", + "group": "navigation", + "when": "!positron-assistant.generatingCommitMessage && config.positron.assistant.gitIntegration.enable" + }, + { + "command": "positron-assistant.cancelGenerateCommitMessage", + "group": "navigation", + "when": "positron-assistant.generatingCommitMessage && config.positron.assistant.gitIntegration.enable" + } + ] + }, "commands": [ { "command": "positron-assistant.configureModels", @@ -116,6 +130,20 @@ "title": "%commands.logStoredModels.title%", "category": "%commands.category%", "enablement": "config.positron.assistant.enable" + }, + { + "command": "positron-assistant.generateCommitMessage", + "title": "%commands.generateCommitMessage.title%", + "category": "%commands.category%", + "enablement": "config.positron.assistant.enable && config.positron.assistant.gitIntegration.enable", + "icon": "$(sparkle)" + }, + { + "command": "positron-assistant.cancelGenerateCommitMessage", + "title": "%commands.cancelGenerateCommitMessage.title%", + "category": "%commands.category%", + "enablement": "config.positron.assistant.enable && config.positron.assistant.gitIntegration.enable", + "icon": "$(stop)" } ], "configuration": [ @@ -146,6 +174,12 @@ "items": { "type": "string" } + }, + "positron.assistant.gitIntegration.enable": { + "type": "boolean", + "default": false, + "description": "%configuration.gitIntegration.description%", + "tags": ["experimental"] } } } @@ -361,6 +395,19 @@ } } }, + { + "name": "getChangedFiles", + "displayName": "Get changed files", + "modelDescription": "Get summaries and git diffs for current changes to files in this workspace.", + "canBeReferencedInPrompt": true, + "userDescription": "Get changed files", + "toolReferenceName": "changes", + "when": "config.positron.assistant.gitIntegration.enable", + "icon": "$(diff)", + "tags": [ + "positron-assistant" + ] + }, { "name": "installPythonPackage", "displayName": "Install Python Package", diff --git a/extensions/positron-assistant/package.nls.json b/extensions/positron-assistant/package.nls.json index 91886d5d4d71..997297b57ca9 100644 --- a/extensions/positron-assistant/package.nls.json +++ b/extensions/positron-assistant/package.nls.json @@ -3,6 +3,8 @@ "description": "Provides default assistant and language models for Positron.", "commands.configureModels.title": "Configure Language Model Providers", "commands.logStoredModels.title": "Log the stored language models", + "commands.generateCommitMessage.title": "Generate Commit Message", + "commands.cancelGenerateCommitMessage.title": "Cancel Generate Commit Message", "commands.copilot.signin.title": "Copilot Sign In", "commands.copilot.signout.title": "Copilot Sign Out", "commands.category": "Positron Assistant", @@ -15,5 +17,6 @@ "configuration.enable.markdownDescription": "Enable [Positron Assistant](https://positron.posit.co/assistant), an AI assistant for Positron.", "configuration.useAnthropicSdk.description": "Use the Anthropic SDK for Anthropic models rather than the generic AI SDK.", "configuration.streamingEdits.enable": "Enable streaming edits in inline editor chats.", - "configuration.inlineCompletionExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from inline completions." + "configuration.inlineCompletionExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from inline completions.", + "configuration.gitIntegration.description": "Enable Positron Assistant git integration." } diff --git a/extensions/positron-assistant/src/extension.ts b/extensions/positron-assistant/src/extension.ts index 5bd28e97c992..540e4d7f5d7b 100644 --- a/extensions/positron-assistant/src/extension.ts +++ b/extensions/positron-assistant/src/extension.ts @@ -14,6 +14,7 @@ import { registerAssistantTools } from './tools.js'; import { registerCopilotService } from './copilot.js'; import { ALL_DOCUMENTS_SELECTOR, DEFAULT_MAX_TOKEN_OUTPUT } from './constants.js'; import { registerCodeActionProvider } from './codeActions.js'; +import { generateCommitMessage } from './git.js'; const hasChatModelsContextKey = 'positron-assistant.hasChatModels'; @@ -208,6 +209,14 @@ function registerConfigureModelsCommand(context: vscode.ExtensionContext, storag ); } +function registerGenerateCommitMessageCommand(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand('positron-assistant.generateCommitMessage', () => { + generateCommitMessage(context); + }) + ); +} + function registerAssistant(context: vscode.ExtensionContext) { // Initialize secret storage. In web mode, we currently need to use global @@ -230,6 +239,7 @@ function registerAssistant(context: vscode.ExtensionContext) { // Commands registerConfigureModelsCommand(context, storage); + registerGenerateCommitMessageCommand(context); // Register mapped edits provider registerMappedEditsProvider(context, participantService, log); diff --git a/extensions/positron-assistant/src/git.ts b/extensions/positron-assistant/src/git.ts new file mode 100644 index 000000000000..79c788f49afd --- /dev/null +++ b/extensions/positron-assistant/src/git.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; + +import { GitExtension, Repository, Status, Change } from '../../git/src/api/git.js'; +import { EXTENSION_ROOT_DIR } from './constants'; + +const mdDir = `${EXTENSION_ROOT_DIR}/src/md/`; +const generatingGitCommitKey = 'positron-assistant.generatingCommitMessage'; + +export enum GitRepoChangeKind { + Staged = 'staged', + Unstaged = 'unstaged', + Merge = 'merge', + Untracked = 'untracked', + All = 'all', +} + +export interface GitRepoChangeSummary { + uri: vscode.Uri; + summary: string; +} + +export interface GitRepoChange { + repo: Repository; + changes: GitRepoChangeSummary[]; +} + +/** Get the list of active repositories */ +function currentGitRepositories(): Repository[] { + // Obtain a handle to git extension API + const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports; + if (!gitExtension) { + throw new Error('Git extension not found'); + } + const git = gitExtension.getAPI(1); + if (git.repositories.length === 0) { + throw new Error('No Git repositories found'); + } + + return git.repositories; +} + +/** Summarise the content of a git change */ +async function gitChangeSummary(repo: Repository, change: Change, kind: GitRepoChangeKind): Promise { + const uri = change.uri.fsPath.replace(repo.rootUri.fsPath, ''); + const originalUri = change.originalUri.fsPath.replace(repo.rootUri.fsPath, ''); + const renameUri = change.renameUri?.fsPath.replace(repo.rootUri.fsPath, ''); + switch (change.status) { + // File-level changes + case Status.INDEX_ADDED: + case Status.UNTRACKED: + return { uri: change.uri, summary: `Added: ${uri}` }; + case Status.INDEX_DELETED: + case Status.DELETED: + return { uri: change.uri, summary: `Deleted: ${uri}` }; + case Status.INDEX_RENAMED: + return { uri: change.uri, summary: `Renamed: ${originalUri} to ${renameUri}` }; + case Status.INDEX_COPIED: + return { uri: change.uri, summary: `Copied: ${originalUri} to ${uri}` }; + case Status.IGNORED: + return { uri: change.uri, summary: `Ignored: ${uri}` }; + default: { + // Otherwise, git diff text content for this file + if (kind === GitRepoChangeKind.Staged) { + const diff = await repo.diffIndexWithHEAD(change.uri.fsPath); + return { uri: change.uri, summary: `Modified:\n${diff}` }; + } else { + const diff = await repo.diffWithHEAD(change.uri.fsPath); + return { uri: change.uri, summary: `Modified:\n${diff}` }; + } + } + } +} + +/** Get current workspace git repository changes as text summaries */ +export async function getWorkspaceGitChanges(kind: GitRepoChangeKind): Promise { + const repos = currentGitRepositories(); + + // Combine and summarise each kind of git repo change + const repoChanges = await Promise.all(repos.map(async (repo) => { + const stateChanges: { change: Change; kind: GitRepoChangeKind }[] = []; + + if (kind === GitRepoChangeKind.Staged || kind === GitRepoChangeKind.All) { + stateChanges.push(...repo.state.indexChanges.map((change) => { + return { change, kind: GitRepoChangeKind.Staged }; + })); + } + if (kind === GitRepoChangeKind.Unstaged || kind === GitRepoChangeKind.All) { + stateChanges.push(...repo.state.workingTreeChanges.map((change) => { + return { change, kind: GitRepoChangeKind.Unstaged }; + })); + } + if (kind === GitRepoChangeKind.Untracked || kind === GitRepoChangeKind.All) { + stateChanges.push(...repo.state.untrackedChanges.map((change) => { + return { change, kind: GitRepoChangeKind.Untracked }; + })); + } + if (kind === GitRepoChangeKind.Merge || kind === GitRepoChangeKind.All) { + stateChanges.push(...repo.state.mergeChanges.map((change) => { + return { change, kind: GitRepoChangeKind.Merge }; + })); + } + + const changes = await Promise.all(stateChanges.map(async (state) => { + return gitChangeSummary(repo, state.change, state.kind); + })); + return { repo, changes }; + })); + + return repoChanges.filter((repoChange) => repoChange.changes.length > 0); +} + +/** Generate a commit message for git repositories with staged changes */ +export async function generateCommitMessage(context: vscode.ExtensionContext) { + await vscode.commands.executeCommand('setContext', generatingGitCommitKey, true); + + const models = (await vscode.lm.selectChatModels()).filter((model) => { + return model.family !== 'echo' && model.family !== 'error'; + }); + if (models.length === 0) { + vscode.commands.executeCommand('setContext', generatingGitCommitKey, false); + throw new Error('No language models available for commit message generation.'); + } + const model = models[0]; + + const tokenSource: vscode.CancellationTokenSource = new vscode.CancellationTokenSource(); + const cancelDisposable = vscode.commands.registerCommand('positron-assistant.cancelGenerateCommitMessage', () => { + tokenSource.cancel(); + vscode.commands.executeCommand('setContext', generatingGitCommitKey, false); + }); + + // Send repo changes to the LLM and update the commit message input boxes + const allChanges = await getWorkspaceGitChanges(GitRepoChangeKind.All); + const stagedChanges = await getWorkspaceGitChanges(GitRepoChangeKind.Staged); + const gitChanges = stagedChanges.length > 0 ? stagedChanges : allChanges; + + const system: string = await fs.promises.readFile(`${mdDir}/prompts/git/commit.md`, 'utf8'); + try { + await Promise.all(gitChanges.map(async ({ repo, changes }) => { + if (changes.length > 0) { + const response = await model.sendRequest([ + vscode.LanguageModelChatMessage.User(changes.map(change => change.summary).join('\n')), + ], { modelOptions: { system } }, tokenSource.token); + + repo.inputBox.value = ''; + for await (const delta of response.text) { + if (tokenSource.token.isCancellationRequested) { + return null; + } + repo.inputBox.value += delta; + } + } + })); + } finally { + cancelDisposable.dispose(); + vscode.commands.executeCommand('setContext', generatingGitCommitKey, false); + } +} diff --git a/extensions/positron-assistant/src/md/prompts/git/commit.md b/extensions/positron-assistant/src/md/prompts/git/commit.md new file mode 100644 index 000000000000..b05ff71ec322 --- /dev/null +++ b/extensions/positron-assistant/src/md/prompts/git/commit.md @@ -0,0 +1,3 @@ +You will be given a set of repository changes and diffs. Output a short git commit message summarizing the changes. + +Return ONLY the commit message. diff --git a/extensions/positron-assistant/src/participants.ts b/extensions/positron-assistant/src/participants.ts index b9d635ea1622..c839aaf43e59 100644 --- a/extensions/positron-assistant/src/participants.ts +++ b/extensions/positron-assistant/src/participants.ts @@ -315,6 +315,16 @@ abstract class PositronAssistantParticipant implements IPositronAssistantPartici } } + // If the user has explicitly attached a tool reference, add it to the prompt. + if (request.toolReferences.length > 0) { + const referencePrompts: string[] = []; + for (const reference of request.toolReferences) { + referencePrompts.push(xml.node('tool', reference.name)); + } + const toolReferencesText = 'Attached tool references:'; + prompts.push(xml.node('tool-references', `${toolReferencesText}\n${referencePrompts.join('\n')}`)); + } + // If the user has explicitly attached files as context, add them to the prompt. if (request.references.length > 0) { const attachmentPrompts: string[] = []; diff --git a/extensions/positron-assistant/src/tools.ts b/extensions/positron-assistant/src/tools.ts index edd78a8d3277..9e8aaa564fd7 100644 --- a/extensions/positron-assistant/src/tools.ts +++ b/extensions/positron-assistant/src/tools.ts @@ -9,6 +9,7 @@ import { LanguageModelImage } from './languageModelParts.js'; import { ParticipantService } from './participants.js'; import { PositronAssistantToolName } from './types.js'; import { ProjectTreeTool } from './tools/projectTreeTool.js'; +import { getWorkspaceGitChanges, GitRepoChangeKind } from './git.js'; import { DocumentCreateTool } from './tools/documentCreate.js'; @@ -281,6 +282,21 @@ export function registerAssistantTools( } }); + const getChangedFilesTool = vscode.lm.registerTool<{}>(PositronAssistantToolName.GetChangedFiles, { + invoke: async (options, token) => { + const repoChanges = await getWorkspaceGitChanges(GitRepoChangeKind.All); + const textChanges = repoChanges.map((({ changes }) => { + return changes.map((change) => change.summary).join('\n'); + })).join('\n\n'); + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(textChanges) + ]); + }, + }); + + context.subscriptions.push(getChangedFilesTool); + context.subscriptions.push(inspectVariablesTool); const installPythonPackageTool = vscode.lm.registerTool<{ diff --git a/extensions/positron-assistant/src/types.ts b/extensions/positron-assistant/src/types.ts index 9e7eac27645a..c0d58479141f 100644 --- a/extensions/positron-assistant/src/types.ts +++ b/extensions/positron-assistant/src/types.ts @@ -12,6 +12,7 @@ export enum PositronAssistantToolName { InspectVariables = 'inspectVariables', SelectionEdit = 'selectionEdit', ProjectTree = 'getProjectTree', + GetChangedFiles = 'getChangedFiles', DocumentCreate = 'documentCreate', TextSearch = 'positron_findTextInProject_internal', FileContents = 'positron_getFileContents_internal',