Merge pull request #284 from RooVetGit/git_mentions

Add a Git section to the context mentions
This commit is contained in:
Matt Rubens
2025-01-06 13:07:25 -05:00
committed by GitHub
18 changed files with 988 additions and 207 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add a Git section to the context mentions

View File

@@ -6,6 +6,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
- Drag and drop images into chats
- Delete messages from chats
- @-mention Git commits to include their context in the chat
- "Enhance prompt" button (OpenRouter models only for now)
- Sound effects for feedback
- Option to use browsers of different sizes and adjust screenshot quality

View File

@@ -12,7 +12,7 @@ import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
import { ApiStream } from "../api/transform/stream"
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import { extractTextFromFile, addLineNumbers, stripLineNumbers, everyLineHasLineNumbers } from "../integrations/misc/extract-text"
import { extractTextFromFile, addLineNumbers, stripLineNumbers, everyLineHasLineNumbers, truncateOutput } from "../integrations/misc/extract-text"
import { TerminalManager } from "../integrations/terminal/TerminalManager"
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
import { listFiles } from "../services/glob/list-files"
@@ -716,22 +716,6 @@ export class Cline {
}
})
const getFormattedOutput = async () => {
const { terminalOutputLineLimit } = await this.providerRef.deref()?.getState() ?? {}
const limit = terminalOutputLineLimit ?? 0
if (limit > 0 && lines.length > limit) {
const beforeLimit = Math.floor(limit * 0.2) // 20% of lines before
const afterLimit = limit - beforeLimit // remaining 80% after
return [
...lines.slice(0, beforeLimit),
`\n[...${lines.length - limit} lines omitted...]\n`,
...lines.slice(-afterLimit)
].join('\n')
}
return lines.join('\n')
}
let completed = false
process.once("completed", () => {
completed = true
@@ -750,7 +734,8 @@ export class Cline {
// grouping command_output messages despite any gaps anyways)
await delay(50)
const output = await getFormattedOutput()
const { terminalOutputLineLimit } = await this.providerRef.deref()?.getState() ?? {}
const output = truncateOutput(lines.join('\n'), terminalOutputLineLimit)
const result = output.trim()
if (userFeedback) {

View File

@@ -0,0 +1,155 @@
// Create mock vscode module before importing anything
const createMockUri = (scheme: string, path: string) => ({
scheme,
authority: '',
path,
query: '',
fragment: '',
fsPath: path,
with: jest.fn(),
toString: () => path,
toJSON: () => ({
scheme,
authority: '',
path,
query: '',
fragment: ''
})
})
const mockExecuteCommand = jest.fn()
const mockOpenExternal = jest.fn()
const mockShowErrorMessage = jest.fn()
const mockVscode = {
workspace: {
workspaceFolders: [{
uri: { fsPath: "/test/workspace" }
}]
},
window: {
showErrorMessage: mockShowErrorMessage,
showInformationMessage: jest.fn(),
showWarningMessage: jest.fn(),
createTextEditorDecorationType: jest.fn(),
createOutputChannel: jest.fn(),
createWebviewPanel: jest.fn(),
activeTextEditor: undefined
},
commands: {
executeCommand: mockExecuteCommand
},
env: {
openExternal: mockOpenExternal
},
Uri: {
parse: jest.fn((url: string) => createMockUri('https', url)),
file: jest.fn((path: string) => createMockUri('file', path))
},
Position: jest.fn(),
Range: jest.fn(),
TextEdit: jest.fn(),
WorkspaceEdit: jest.fn(),
DiagnosticSeverity: {
Error: 0,
Warning: 1,
Information: 2,
Hint: 3
}
}
// Mock modules
jest.mock('vscode', () => mockVscode)
jest.mock("../../../services/browser/UrlContentFetcher")
jest.mock("../../../utils/git")
// Now import the modules that use the mocks
import { parseMentions, openMention } from "../index"
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
import * as git from "../../../utils/git"
describe("mentions", () => {
const mockCwd = "/test/workspace"
let mockUrlContentFetcher: UrlContentFetcher
beforeEach(() => {
jest.clearAllMocks()
// Create a mock instance with just the methods we need
mockUrlContentFetcher = {
launchBrowser: jest.fn().mockResolvedValue(undefined),
closeBrowser: jest.fn().mockResolvedValue(undefined),
urlToMarkdown: jest.fn().mockResolvedValue(""),
} as unknown as UrlContentFetcher
})
describe("parseMentions", () => {
it("should parse git commit mentions", async () => {
const commitHash = "abc1234"
const commitInfo = `abc1234 Fix bug in parser
Author: John Doe
Date: Mon Jan 5 23:50:06 2025 -0500
Detailed commit message with multiple lines
- Fixed parsing issue
- Added tests`
jest.mocked(git.getCommitInfo).mockResolvedValue(commitInfo)
const result = await parseMentions(
`Check out this commit @${commitHash}`,
mockCwd,
mockUrlContentFetcher
)
expect(result).toContain(`'${commitHash}' (see below for commit info)`)
expect(result).toContain(`<git_commit hash="${commitHash}">`)
expect(result).toContain(commitInfo)
})
it("should handle errors fetching git info", async () => {
const commitHash = "abc1234"
const errorMessage = "Failed to get commit info"
jest.mocked(git.getCommitInfo).mockRejectedValue(new Error(errorMessage))
const result = await parseMentions(
`Check out this commit @${commitHash}`,
mockCwd,
mockUrlContentFetcher
)
expect(result).toContain(`'${commitHash}' (see below for commit info)`)
expect(result).toContain(`<git_commit hash="${commitHash}">`)
expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
})
})
describe("openMention", () => {
it("should handle file paths and problems", async () => {
await openMention("/path/to/file")
expect(mockExecuteCommand).not.toHaveBeenCalled()
expect(mockOpenExternal).not.toHaveBeenCalled()
expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file!")
await openMention("problems")
expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
})
it("should handle URLs", async () => {
const url = "https://example.com"
await openMention(url)
const mockUri = mockVscode.Uri.parse(url)
expect(mockOpenExternal).toHaveBeenCalled()
const calledArg = mockOpenExternal.mock.calls[0][0]
expect(calledArg).toEqual(expect.objectContaining({
scheme: mockUri.scheme,
authority: mockUri.authority,
path: mockUri.path,
query: mockUri.query,
fragment: mockUri.fragment
}))
})
})
})

View File

@@ -2,27 +2,28 @@ import * as vscode from "vscode"
import * as path from "path"
import { openFile } from "../../integrations/misc/open-file"
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { mentionRegexGlobal } from "../../shared/context-mentions"
import { mentionRegexGlobal, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions"
import fs from "fs/promises"
import { extractTextFromFile } from "../../integrations/misc/extract-text"
import { isBinaryFile } from "isbinaryfile"
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
import { getCommitInfo, getWorkingState } from "../../utils/git"
export function openMention(mention?: string): void {
export async function openMention(mention?: string): Promise<void> {
if (!mention) {
return
}
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
return
}
if (mention.startsWith("/")) {
const relPath = mention.slice(1)
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
return
}
const absPath = path.resolve(cwd, relPath)
if (mention.endsWith("/")) {
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
// vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window
} else {
openFile(absPath)
}
@@ -40,12 +41,16 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
if (mention.startsWith("http")) {
return `'${mention}' (see below for site content)`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1) // Remove the leading '/'
const mentionPath = mention.slice(1)
return mentionPath.endsWith("/")
? `'${mentionPath}' (see below for folder content)`
: `'${mentionPath}' (see below for file content)`
} else if (mention === "problems") {
return `Workspace Problems (see below for diagnostics)`
} else if (mention === "git-changes") {
return `Working directory changes (see below for details)`
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
return `Git commit '${mention}' (see below for commit info)`
}
return match
})
@@ -99,6 +104,20 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
} catch (error) {
parsedText += `\n\n<workspace_diagnostics>\nError fetching diagnostics: ${error.message}\n</workspace_diagnostics>`
}
} else if (mention === "git-changes") {
try {
const workingState = await getWorkingState(cwd)
parsedText += `\n\n<git_working_state>\n${workingState}\n</git_working_state>`
} catch (error) {
parsedText += `\n\n<git_working_state>\nError fetching working state: ${error.message}\n</git_working_state>`
}
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
try {
const commitInfo = await getCommitInfo(mention, cwd)
parsedText += `\n\n<git_commit hash="${mention}">\n${commitInfo}\n</git_commit>`
} catch (error) {
parsedText += `\n\n<git_commit hash="${mention}">\nError fetching commit info: ${error.message}\n</git_commit>`
}
}
}
@@ -137,7 +156,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
folderContent += `${linePrefix}${entry.name}\n`
const filePath = path.join(mentionPath, entry.name)
const absoluteFilePath = path.resolve(absPath, entry.name)
// const relativeFilePath = path.relative(cwd, absoluteFilePath);
fileContentPromises.push(
(async () => {
try {
@@ -154,7 +172,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
)
} else if (entry.isDirectory()) {
folderContent += `${linePrefix}${entry.name}/\n`
// not recursively getting folder contents
} else {
folderContent += `${linePrefix}${entry.name}\n`
}

View File

@@ -24,6 +24,7 @@ import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { enhancePrompt } from "../../utils/enhance-prompt"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -732,6 +733,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
break
case "searchCommits": {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (cwd) {
try {
const commits = await searchCommits(message.query || "", cwd)
await this.postMessageToWebview({
type: "commitSearchResults",
commits
})
} catch (error) {
console.error("Error searching commits:", error)
vscode.window.showErrorMessage("Failed to search commits")
}
}
break
}
}
},
null,

View File

@@ -1,4 +1,4 @@
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from '../extract-text';
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers, truncateOutput } from '../extract-text';
describe('addLineNumbers', () => {
it('should add line numbers starting from 1 by default', () => {
@@ -100,10 +100,77 @@ describe('stripLineNumbers', () => {
const expected = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should preserve indentation after line numbers', () => {
const input = '1 | indented line\n2 | another indented';
const expected = ' indented line\n another indented';
expect(stripLineNumbers(input)).toBe(expected);
});
});
describe('truncateOutput', () => {
it('returns original content when no line limit provided', () => {
const content = 'line1\nline2\nline3'
expect(truncateOutput(content)).toBe(content)
})
it('returns original content when lines are under limit', () => {
const content = 'line1\nline2\nline3'
expect(truncateOutput(content, 5)).toBe(content)
})
it('truncates content with 20/80 split when over limit', () => {
// Create 25 lines of content
const lines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`)
const content = lines.join('\n')
// Set limit to 10 lines
const result = truncateOutput(content, 10)
// Should keep:
// - First 2 lines (20% of 10)
// - Last 8 lines (80% of 10)
// - Omission indicator in between
const expectedLines = [
'line1',
'line2',
'',
'[...15 lines omitted...]',
'',
'line18',
'line19',
'line20',
'line21',
'line22',
'line23',
'line24',
'line25'
]
expect(result).toBe(expectedLines.join('\n'))
})
it('handles empty content', () => {
expect(truncateOutput('', 10)).toBe('')
})
it('handles single line content', () => {
expect(truncateOutput('single line', 10)).toBe('single line')
})
it('handles windows-style line endings', () => {
// Create content with windows line endings
const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`)
const content = lines.join('\r\n')
const result = truncateOutput(content, 5)
// Should keep first line (20% of 5 = 1) and last 4 lines (80% of 5 = 4)
// Split result by either \r\n or \n to normalize line endings
const resultLines = result.split(/\r?\n/)
const expectedLines = [
'line1',
'',
'[...10 lines omitted...]',
'',
'line12',
'line13',
'line14',
'line15'
]
expect(resultLines).toEqual(expectedLines)
})
})

View File

@@ -88,3 +88,37 @@ export function stripLineNumbers(content: string): string {
const lineEnding = content.includes('\r\n') ? '\r\n' : '\n'
return processedLines.join(lineEnding)
}
/**
* Truncates multi-line output while preserving context from both the beginning and end.
* When truncation is needed, it keeps 20% of the lines from the start and 80% from the end,
* with a clear indicator of how many lines were omitted in between.
*
* @param content The multi-line string to truncate
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content
* @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed
*
* @example
* // With 10 line limit on 25 lines of content:
* // - Keeps first 2 lines (20% of 10)
* // - Keeps last 8 lines (80% of 10)
* // - Adds "[...15 lines omitted...]" in between
*/
export function truncateOutput(content: string, lineLimit?: number): string {
if (!lineLimit) {
return content
}
const lines = content.split('\n')
if (lines.length <= lineLimit) {
return content
}
const beforeLimit = Math.floor(lineLimit * 0.2) // 20% of lines before
const afterLimit = lineLimit - beforeLimit // remaining 80% after
return [
...lines.slice(0, beforeLimit),
`\n[...${lines.length - lineLimit} lines omitted...]\n`,
...lines.slice(-afterLimit)
].join('\n')
}

View File

@@ -3,6 +3,7 @@
import { ApiConfiguration, ModelInfo } from "./api"
import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git"
// webview will hold state
export interface ExtensionMessage {
@@ -21,6 +22,7 @@ export interface ExtensionMessage {
| "openAiModels"
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
text?: string
action?:
| "chatButtonClicked"
@@ -39,6 +41,7 @@ export interface ExtensionMessage {
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
commits?: GitCommit[]
}
export interface ExtensionState {

View File

@@ -51,6 +51,7 @@ export interface WebviewMessage {
| "deleteMessage"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "searchCommits"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
@@ -65,6 +66,7 @@ export interface WebviewMessage {
alwaysAllow?: boolean
dataUrls?: string[]
values?: Record<string, any>
query?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"

View File

@@ -9,7 +9,7 @@ Mention regex:
- `/@`:
- **@**: The mention must start with the '@' symbol.
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b)`:
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b|git-changes\b)`:
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
- `(?:\/|\w+:\/\/)`:
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
@@ -25,6 +25,10 @@ Mention regex:
- `problems\b`:
- **Exact Word ('problems')**: Matches the exact word 'problems'.
- **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic').
- `|`: Logical OR.
- `problems\b`:
- **Exact Word ('git-changes')**: Matches the exact word 'git-changes'.
- **Word Boundary (`\b`)**: Ensures that 'git-changes' is matched as a whole word and not as part of another word.
- `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
@@ -38,11 +42,44 @@ Mention regex:
- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
- The exact word 'problems'.
- The exact word 'git-changes'.
- It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text.
- **Global Regex**:
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
*/
export const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+?|problems\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
export interface MentionSuggestion {
type: 'file' | 'folder' | 'git' | 'problems'
label: string
description?: string
value: string
icon?: string
}
export interface GitMentionSuggestion extends MentionSuggestion {
type: 'git'
hash: string
shortHash: string
subject: string
author: string
date: string
}
export function formatGitSuggestion(commit: { hash: string; shortHash: string; subject: string; author: string; date: string }): GitMentionSuggestion {
return {
type: 'git',
label: commit.subject,
description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
value: commit.hash,
icon: '$(git-commit)', // VSCode git commit icon
hash: commit.hash,
shortHash: commit.shortHash,
subject: commit.subject,
author: commit.author,
date: commit.date
}
}

166
src/utils/git.ts Normal file
View File

@@ -0,0 +1,166 @@
import { exec } from "child_process"
import { promisify } from "util"
import { truncateOutput } from "../integrations/misc/extract-text"
const execAsync = promisify(exec)
const GIT_OUTPUT_LINE_LIMIT = 500
export interface GitCommit {
hash: string
shortHash: string
subject: string
author: string
date: string
}
async function checkGitRepo(cwd: string): Promise<boolean> {
try {
await execAsync('git rev-parse --git-dir', { cwd })
return true
} catch (error) {
return false
}
}
async function checkGitInstalled(): Promise<boolean> {
try {
await execAsync('git --version')
return true
} catch (error) {
return false
}
}
export async function searchCommits(query: string, cwd: string): Promise<GitCommit[]> {
try {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
console.error("Git is not installed")
return []
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
console.error("Not a git repository")
return []
}
// Search commits by hash or message, limiting to 10 results
const { stdout } = await execAsync(
`git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` +
`--grep="${query}" --regexp-ignore-case`,
{ cwd }
)
let output = stdout
if (!output.trim() && /^[a-f0-9]+$/i.test(query)) {
// If no results from grep search and query looks like a hash, try searching by hash
const { stdout: hashStdout } = await execAsync(
`git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` +
`--author-date-order ${query}`,
{ cwd }
).catch(() => ({ stdout: "" }))
if (!hashStdout.trim()) {
return []
}
output = hashStdout
}
const commits: GitCommit[] = []
const lines = output.trim().split("\n").filter(line => line !== "--")
for (let i = 0; i < lines.length; i += 5) {
commits.push({
hash: lines[i],
shortHash: lines[i + 1],
subject: lines[i + 2],
author: lines[i + 3],
date: lines[i + 4]
})
}
return commits
} catch (error) {
console.error("Error searching commits:", error)
return []
}
}
export async function getCommitInfo(hash: string, cwd: string): Promise<string> {
try {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return "Git is not installed"
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return "Not a git repository"
}
// Get commit info, stats, and diff separately
const { stdout: info } = await execAsync(
`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`,
{ cwd }
)
const [fullHash, shortHash, subject, author, date, body] = info.trim().split('\n')
const { stdout: stats } = await execAsync(
`git show --stat --format="" ${hash}`,
{ cwd }
)
const { stdout: diff } = await execAsync(
`git show --format="" ${hash}`,
{ cwd }
)
const summary = [
`Commit: ${shortHash} (${fullHash})`,
`Author: ${author}`,
`Date: ${date}`,
`\nMessage: ${subject}`,
body ? `\nDescription:\n${body}` : '',
'\nFiles Changed:',
stats.trim(),
'\nFull Changes:'
].join('\n')
const output = summary + '\n\n' + diff.trim()
return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT)
} catch (error) {
console.error("Error getting commit info:", error)
return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}`
}
}
export async function getWorkingState(cwd: string): Promise<string> {
try {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return "Git is not installed"
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return "Not a git repository"
}
// Get status of working directory
const { stdout: status } = await execAsync('git status --short', { cwd })
if (!status.trim()) {
return "No changes in working directory"
}
// Get all changes (both staged and unstaged) compared to HEAD
const { stdout: diff } = await execAsync('git diff HEAD', { cwd })
const lineLimit = GIT_OUTPUT_LINE_LIMIT
const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim()
return truncateOutput(output, lineLimit)
} catch (error) {
console.error("Error getting working state:", error)
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
}
}

View File

@@ -12,8 +12,8 @@ import {
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import Thumbnails from "../common/Thumbnails"
import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
interface ChatTextAreaProps {
inputValue: string
@@ -46,6 +46,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
) => {
const { filePaths, apiConfiguration } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([])
// Handle enhanced prompt response
useEffect(() => {
@@ -54,6 +55,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (message.type === 'enhancedPrompt' && message.text) {
setInputValue(message.text)
setIsEnhancingPrompt(false)
} else if (message.type === 'commitSearchResults') {
const commits = message.commits.map((commit: any) => ({
type: ContextMenuOptionType.Git,
value: commit.hash,
label: commit.subject,
description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
icon: "$(git-commit)"
}))
setGitCommits(commits)
}
}
window.addEventListener('message', messageHandler)
@@ -73,9 +83,19 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
// Fetch git commits when Git is selected or when typing a hash
useEffect(() => {
if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) {
const message: WebviewMessage = {
type: "searchCommits",
query: searchQuery || ""
} as const
vscode.postMessage(message)
}
}, [selectedType, searchQuery])
const handleEnhancePrompt = useCallback(() => {
if (!textAreaDisabled) {
const trimmedInput = inputValue.trim()
@@ -96,6 +116,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const queryItems = useMemo(() => {
return [
{ type: ContextMenuOptionType.Problems, value: "problems" },
...gitCommits,
...filePaths
.map((file) => "/" + file)
.map((path) => ({
@@ -103,7 +124,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
value: path,
})),
]
}, [filePaths])
}, [filePaths, gitCommits])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -130,7 +151,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
if (type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder ||
type === ContextMenuOptionType.Git) {
if (!value) {
setSelectedType(type)
setSearchQuery("")
@@ -149,6 +172,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
insertValue = value || ""
} else if (type === ContextMenuOptionType.Problems) {
insertValue = "problems"
} else if (type === ContextMenuOptionType.Git) {
insertValue = value || ""
}
const { newValue, mentionIndex } = insertMention(
@@ -161,7 +186,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
// textAreaRef.current.focus()
// scroll to cursor
setTimeout(() => {
@@ -179,7 +203,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showContextMenu) {
if (event.key === "Escape") {
// event.preventDefault()
setSelectedType(null)
setSelectedMenuIndex(3) // File by default
return
@@ -356,19 +379,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setShowContextMenu(false)
// Scroll to new cursor position
// https://stackoverflow.com/questions/29899364/how-do-you-scroll-to-the-position-of-the-cursor-in-a-textarea/40951875#40951875
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.blur()
textAreaRef.current.focus()
}
}, 0)
// NOTE: callbacks dont utilize return function to cleanup, but it's fine since this timeout immediately executes and will be cleaned up by the browser (no chance component unmounts before it executes)
return
}
const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg)
const acceptedTypes = ["png", "jpeg", "webp"]
const imageItems = Array.from(items).filter((item) => {
const [type, subtype] = item.type.split("/")
return type === "image" && acceptedTypes.includes(subtype)
@@ -397,7 +418,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
})
const imageDataArray = await Promise.all(imagePromises)
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
//.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it
if (dataUrls.length > 0) {
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
} else {
@@ -602,7 +622,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
boxSizing: "border-box",
backgroundColor: "transparent",
color: "var(--vscode-input-foreground)",
//border: "1px solid var(--vscode-input-border)",
borderRadius: 2,
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
@@ -610,18 +629,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
resize: "none",
overflowX: "hidden",
overflowY: "scroll",
// Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410)
// borderTop: "9px solid transparent",
borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
borderColor: "transparent",
padding: "9px 9px 25px 9px",
// borderRight: "54px solid transparent",
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: 1,
zIndex: 1,

View File

@@ -52,6 +52,26 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return <span>Paste URL to fetch contents</span>
case ContextMenuOptionType.NoResults:
return <span>No results found</span>
case ContextMenuOptionType.Git:
if (option.value) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<span style={{ lineHeight: '1.2' }}>{option.label}</span>
<span style={{
fontSize: '0.85em',
opacity: 0.7,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.2'
}}>
{option.description}
</span>
</div>
)
} else {
return <span>Git Commits</span>
}
case ContextMenuOptionType.File:
case ContextMenuOptionType.Folder:
if (option.value) {
@@ -87,6 +107,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return "warning"
case ContextMenuOptionType.URL:
return "link"
case ContextMenuOptionType.Git:
return "git-commit"
case ContextMenuOptionType.NoResults:
return "info"
default:
@@ -121,7 +143,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
maxHeight: "200px",
overflowY: "auto",
}}>
{/* Can't use virtuoso since it requires fixed height and menu height is dynamic based on # of items */}
{filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
@@ -147,15 +168,23 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
flex: 1,
minWidth: 0,
overflow: "hidden",
paddingTop: 0
}}>
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }}
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0
}}
/>
{renderOptionContent(option)}
</div>
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
!option.value && (
{((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
!option.value) && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
@@ -163,7 +192,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
)}
{(option.type === ContextMenuOptionType.Problems ||
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder) &&
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
<i
className="codicon codicon-add"

View File

@@ -100,6 +100,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
config.lmStudioModelId,
config.geminiApiKey,
config.openAiNativeApiKey,
config.deepSeekApiKey,
].some((key) => key !== undefined)
: false
setShowWelcome(!hasKey)

View File

@@ -0,0 +1,46 @@
import { vscode } from "../utils/vscode"
export interface GitCommit {
hash: string
shortHash: string
subject: string
author: string
date: string
}
class GitService {
private commits: GitCommit[] | null = null
private lastQuery: string = ''
async searchCommits(query: string = ''): Promise<GitCommit[]> {
if (query === this.lastQuery && this.commits) {
return this.commits
}
// Request search from extension
vscode.postMessage({ type: 'searchCommits', query })
// Wait for response
const response = await new Promise<GitCommit[]>((resolve) => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'commitSearchResults') {
window.removeEventListener('message', handler)
resolve(message.commits)
}
}
window.addEventListener('message', handler)
})
this.commits = response
this.lastQuery = query
return response
}
clearCache() {
this.commits = null
this.lastQuery = ''
}
}
export const gitService = new GitService()

View File

@@ -0,0 +1,130 @@
import { insertMention, removeMention, getContextMenuOptions, shouldShowContextMenu, ContextMenuOptionType, ContextMenuQueryItem } from '../context-mentions'
describe('insertMention', () => {
it('should insert mention at cursor position when no @ symbol exists', () => {
const result = insertMention('Hello world', 5, 'test')
expect(result.newValue).toBe('Hello@test world')
expect(result.mentionIndex).toBe(5)
})
it('should replace text after last @ symbol', () => {
const result = insertMention('Hello @wor world', 8, 'test')
expect(result.newValue).toBe('Hello @test world')
expect(result.mentionIndex).toBe(6)
})
it('should handle empty text', () => {
const result = insertMention('', 0, 'test')
expect(result.newValue).toBe('@test ')
expect(result.mentionIndex).toBe(0)
})
})
describe('removeMention', () => {
it('should remove mention when cursor is at end of mention', () => {
// Test with the problems keyword that matches the regex
const result = removeMention('Hello @problems ', 15)
expect(result.newText).toBe('Hello ')
expect(result.newPosition).toBe(6)
})
it('should not remove text when not at end of mention', () => {
const result = removeMention('Hello @test world', 8)
expect(result.newText).toBe('Hello @test world')
expect(result.newPosition).toBe(8)
})
it('should handle text without mentions', () => {
const result = removeMention('Hello world', 5)
expect(result.newText).toBe('Hello world')
expect(result.newPosition).toBe(5)
})
})
describe('getContextMenuOptions', () => {
const mockQueryItems: ContextMenuQueryItem[] = [
{
type: ContextMenuOptionType.File,
value: 'src/test.ts',
label: 'test.ts',
description: 'Source file'
},
{
type: ContextMenuOptionType.Git,
value: 'abc1234',
label: 'Initial commit',
description: 'First commit',
icon: '$(git-commit)'
},
{
type: ContextMenuOptionType.Folder,
value: 'src',
label: 'src',
description: 'Source folder'
}
]
it('should return all option types for empty query', () => {
const result = getContextMenuOptions('', null, [])
expect(result).toHaveLength(5)
expect(result.map(item => item.type)).toEqual([
ContextMenuOptionType.Problems,
ContextMenuOptionType.URL,
ContextMenuOptionType.Folder,
ContextMenuOptionType.File,
ContextMenuOptionType.Git
])
})
it('should filter by selected type when query is empty', () => {
const result = getContextMenuOptions('', ContextMenuOptionType.File, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.File)
expect(result[0].value).toBe('src/test.ts')
})
it('should match git commands', () => {
const result = getContextMenuOptions('git', null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].label).toBe('Git Commits')
})
it('should match git commit hashes', () => {
const result = getContextMenuOptions('abc1234', null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].value).toBe('abc1234')
})
it('should return NoResults when no matches found', () => {
const result = getContextMenuOptions('nonexistent', null, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
})
})
describe('shouldShowContextMenu', () => {
it('should return true for @ symbol', () => {
expect(shouldShowContextMenu('@', 1)).toBe(true)
})
it('should return true for @ followed by text', () => {
expect(shouldShowContextMenu('Hello @test', 10)).toBe(true)
})
it('should return false when no @ symbol exists', () => {
expect(shouldShowContextMenu('Hello world', 5)).toBe(false)
})
it('should return false for @ followed by whitespace', () => {
expect(shouldShowContextMenu('Hello @ world', 6)).toBe(false)
})
it('should return false for @ in URL', () => {
expect(shouldShowContextMenu('Hello @http://test.com', 17)).toBe(false)
})
it('should return false for @problems', () => {
// Position cursor at the end to test the full word
expect(shouldShowContextMenu('@problems', 9)).toBe(false)
})
})

View File

@@ -51,12 +51,16 @@ export enum ContextMenuOptionType {
Folder = "folder",
Problems = "problems",
URL = "url",
Git = "git",
NoResults = "noResults",
}
export interface ContextMenuQueryItem {
type: ContextMenuOptionType
value?: string
label?: string
description?: string
icon?: string
}
export function getContextMenuOptions(
@@ -64,6 +68,14 @@ export function getContextMenuOptions(
selectedType: ContextMenuOptionType | null = null,
queryItems: ContextMenuQueryItem[],
): ContextMenuQueryItem[] {
const workingChanges: ContextMenuQueryItem = {
type: ContextMenuOptionType.Git,
value: "git-changes",
label: "Working changes",
description: "Current uncommitted changes",
icon: "$(git-commit)"
}
if (query === "") {
if (selectedType === ContextMenuOptionType.File) {
const files = queryItems
@@ -79,30 +91,88 @@ export function getContextMenuOptions(
return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
}
if (selectedType === ContextMenuOptionType.Git) {
const commits = queryItems
.filter((item) => item.type === ContextMenuOptionType.Git)
return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges]
}
return [
{ type: ContextMenuOptionType.URL },
{ type: ContextMenuOptionType.Problems },
{ type: ContextMenuOptionType.URL },
{ type: ContextMenuOptionType.Folder },
{ type: ContextMenuOptionType.File },
{ type: ContextMenuOptionType.Git },
]
}
const lowerQuery = query.toLowerCase()
const suggestions: ContextMenuQueryItem[] = []
// Check for top-level option matches
if ("git".startsWith(lowerQuery)) {
suggestions.push({
type: ContextMenuOptionType.Git,
label: "Git Commits",
description: "Search repository history",
icon: "$(git-commit)"
})
} else if ("git-changes".startsWith(lowerQuery)) {
suggestions.push(workingChanges)
}
if ("problems".startsWith(lowerQuery)) {
suggestions.push({ type: ContextMenuOptionType.Problems })
}
if (query.startsWith("http")) {
return [{ type: ContextMenuOptionType.URL, value: query }]
} else {
const matchingItems = queryItems.filter((item) => item.value?.toLowerCase().includes(lowerQuery))
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
}
if (matchingItems.length > 0) {
return matchingItems.map((item) => ({
type: item.type,
value: item.value,
}))
// Add exact SHA matches to suggestions
if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {
const exactMatches = queryItems.filter((item) =>
item.type === ContextMenuOptionType.Git &&
item.value?.toLowerCase() === lowerQuery
)
if (exactMatches.length > 0) {
suggestions.push(...exactMatches)
} else {
// If no exact match but valid SHA format, add as option
suggestions.push({
type: ContextMenuOptionType.Git,
value: lowerQuery,
label: `Commit ${lowerQuery}`,
description: "Git commit hash",
icon: "$(git-commit)"
})
}
}
// Get matching items, separating by type
const matchingItems = queryItems.filter((item) =>
item.value?.toLowerCase().includes(lowerQuery) ||
item.label?.toLowerCase().includes(lowerQuery) ||
item.description?.toLowerCase().includes(lowerQuery)
)
const fileMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.File ||
item.type === ContextMenuOptionType.Folder
)
const gitMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.Git
)
const otherMatches = matchingItems.filter(item =>
item.type !== ContextMenuOptionType.File &&
item.type !== ContextMenuOptionType.Folder &&
item.type !== ContextMenuOptionType.Git
)
// Combine suggestions with matching items in the desired order
if (suggestions.length > 0 || matchingItems.length > 0) {
return [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
}
return [{ type: ContextMenuOptionType.NoResults }]
}
}
}
export function shouldShowContextMenu(text: string, position: number): boolean {