merge(upstream): merge upstream changes keeping VSCode LM provider and adding Glama support

This commit is contained in:
RaySinner
2025-01-07 01:54:46 +03:00
29 changed files with 2040 additions and 280 deletions

View File

@@ -1,4 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { GlamaHandler } from "./providers/glama"
import { ApiConfiguration, ModelInfo } from "../shared/api"
import { AnthropicHandler } from "./providers/anthropic"
import { AwsBedrockHandler } from "./providers/bedrock"
@@ -28,6 +29,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
case "glama":
return new GlamaHandler(options)
case "openrouter":
return new OpenRouterHandler(options)
case "bedrock":

View File

@@ -0,0 +1,192 @@
import { OpenAiHandler } from '../openai'
import { ApiHandlerOptions, openAiModelInfoSaneDefaults } from '../../../shared/api'
import OpenAI, { AzureOpenAI } from 'openai'
import { Anthropic } from '@anthropic-ai/sdk'
// Mock dependencies
jest.mock('openai')
describe('OpenAiHandler', () => {
const mockOptions: ApiHandlerOptions = {
openAiApiKey: 'test-key',
openAiModelId: 'gpt-4',
openAiStreamingEnabled: true,
openAiBaseUrl: 'https://api.openai.com/v1'
}
beforeEach(() => {
jest.clearAllMocks()
})
test('constructor initializes with correct options', () => {
const handler = new OpenAiHandler(mockOptions)
expect(handler).toBeInstanceOf(OpenAiHandler)
expect(OpenAI).toHaveBeenCalledWith({
apiKey: mockOptions.openAiApiKey,
baseURL: mockOptions.openAiBaseUrl
})
})
test('constructor initializes Azure client when Azure URL is provided', () => {
const azureOptions: ApiHandlerOptions = {
...mockOptions,
openAiBaseUrl: 'https://example.azure.com',
azureApiVersion: '2023-05-15'
}
const handler = new OpenAiHandler(azureOptions)
expect(handler).toBeInstanceOf(OpenAiHandler)
expect(AzureOpenAI).toHaveBeenCalledWith({
baseURL: azureOptions.openAiBaseUrl,
apiKey: azureOptions.openAiApiKey,
apiVersion: azureOptions.azureApiVersion
})
})
test('getModel returns correct model info', () => {
const handler = new OpenAiHandler(mockOptions)
const result = handler.getModel()
expect(result).toEqual({
id: mockOptions.openAiModelId,
info: openAiModelInfoSaneDefaults
})
})
test('createMessage handles streaming correctly when enabled', async () => {
const handler = new OpenAiHandler({
...mockOptions,
openAiStreamingEnabled: true,
includeMaxTokens: true
})
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
choices: [{
delta: {
content: 'test response'
}
}],
usage: {
prompt_tokens: 10,
completion_tokens: 5
}
}
}
}
const mockCreate = jest.fn().mockResolvedValue(mockStream)
;(OpenAI as jest.MockedClass<typeof OpenAI>).prototype.chat = {
completions: { create: mockCreate }
} as any
const systemPrompt = 'test system prompt'
const messages: Anthropic.Messages.MessageParam[] = [
{ role: 'user', content: 'test message' }
]
const generator = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
expect(chunks).toEqual([
{
type: 'text',
text: 'test response'
},
{
type: 'usage',
inputTokens: 10,
outputTokens: 5
}
])
expect(mockCreate).toHaveBeenCalledWith({
model: mockOptions.openAiModelId,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: 'test message' }
],
temperature: 0,
stream: true,
stream_options: { include_usage: true },
max_tokens: openAiModelInfoSaneDefaults.maxTokens
})
})
test('createMessage handles non-streaming correctly when disabled', async () => {
const handler = new OpenAiHandler({
...mockOptions,
openAiStreamingEnabled: false
})
const mockResponse = {
choices: [{
message: {
content: 'test response'
}
}],
usage: {
prompt_tokens: 10,
completion_tokens: 5
}
}
const mockCreate = jest.fn().mockResolvedValue(mockResponse)
;(OpenAI as jest.MockedClass<typeof OpenAI>).prototype.chat = {
completions: { create: mockCreate }
} as any
const systemPrompt = 'test system prompt'
const messages: Anthropic.Messages.MessageParam[] = [
{ role: 'user', content: 'test message' }
]
const generator = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
expect(chunks).toEqual([
{
type: 'text',
text: 'test response'
},
{
type: 'usage',
inputTokens: 10,
outputTokens: 5
}
])
expect(mockCreate).toHaveBeenCalledWith({
model: mockOptions.openAiModelId,
messages: [
{ role: 'user', content: systemPrompt },
{ role: 'user', content: 'test message' }
]
})
})
test('createMessage handles API errors', async () => {
const handler = new OpenAiHandler(mockOptions)
const mockStream = {
async *[Symbol.asyncIterator]() {
throw new Error('API Error')
}
}
const mockCreate = jest.fn().mockResolvedValue(mockStream)
;(OpenAI as jest.MockedClass<typeof OpenAI>).prototype.chat = {
completions: { create: mockCreate }
} as any
const generator = handler.createMessage('test', [])
await expect(generator.next()).rejects.toThrow('API Error')
})
})

132
src/api/providers/glama.ts Normal file
View File

@@ -0,0 +1,132 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, glamaDefaultModelId, glamaDefaultModelInfo } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import delay from "delay"
export class GlamaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://glama.ai/api/gateway/openai/v1",
apiKey: this.options.glamaApiKey,
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Convert Anthropic messages to OpenAI format
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
if (this.getModel().id.startsWith("anthropic/claude-3")) {
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time,
// but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end.
// but if it weren't there, and the user added a image_url type message,
// it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
}
// Required by Anthropic
// Other providers default to max tokens allowed.
let maxTokens: number | undefined
if (this.getModel().id.startsWith("anthropic/")) {
maxTokens = 8_192
}
const { data: completion, response } = await this.client.chat.completions.create({
model: this.getModel().id,
max_tokens: maxTokens,
temperature: 0,
messages: openAiMessages,
stream: true,
}).withResponse();
const completionRequestId = response.headers.get(
'x-completion-request-id',
);
for await (const chunk of completion) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
}
try {
const response = await axios.get(`https://glama.ai/api/gateway/v1/completion-requests/${completionRequestId}`, {
headers: {
Authorization: `Bearer ${this.options.glamaApiKey}`,
},
})
const completionRequest = response.data;
if (completionRequest.tokenUsage) {
yield {
type: "usage",
cacheWriteTokens: completionRequest.tokenUsage.cacheCreationInputTokens,
cacheReadTokens: completionRequest.tokenUsage.cacheReadInputTokens,
inputTokens: completionRequest.tokenUsage.promptTokens,
outputTokens: completionRequest.tokenUsage.completionTokens,
totalCost: parseFloat(completionRequest.totalCostUsd),
}
}
} catch (error) {
console.error("Error fetching Glama completion details", error)
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.glamaModelId
const modelInfo = this.options.glamaModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return { id: glamaDefaultModelId, info: glamaDefaultModelInfo }
}
}

View File

@@ -32,42 +32,64 @@ export class OpenAiHandler implements ApiHandler {
}
}
// Include stream_options for OpenAI Compatible providers if the checkbox is checked
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const modelInfo = this.getModel().info
const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = {
model: this.options.openAiModelId ?? "",
messages: openAiMessages,
temperature: 0,
stream: true,
}
if (this.options.includeMaxTokens) {
requestOptions.max_tokens = modelInfo.maxTokens
}
const modelId = this.options.openAiModelId ?? ""
if (this.options.includeStreamOptions ?? true) {
requestOptions.stream_options = { include_usage: true }
}
if (this.options.openAiStreamingEnabled ?? true) {
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt
}
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
temperature: 0,
messages: [systemMessage, ...convertToOpenAiMessages(messages)],
stream: true as const,
stream_options: { include_usage: true },
}
if (this.options.includeMaxTokens) {
requestOptions.max_tokens = modelInfo.maxTokens
}
const stream = await this.client.chat.completions.create(requestOptions)
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
const stream = await this.client.chat.completions.create(requestOptions)
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
} else {
// o1 for instance doesnt support streaming, non-1 temp, or system prompt
const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content: systemPrompt
}
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: [systemMessage, ...convertToOpenAiMessages(messages)],
}
const response = await this.client.chat.completions.create(requestOptions)
yield {
type: "text",
text: response.choices[0]?.message.content || "",
}
yield {
type: "usage",
inputTokens: response.usage?.prompt_tokens || 0,
outputTokens: response.usage?.completion_tokens || 0,
}
}
}

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
@@ -33,6 +34,7 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
type SecretKey =
| "apiKey"
| "glamaApiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
@@ -45,6 +47,8 @@ type SecretKey =
type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "glamaModelId"
| "glamaModelInfo"
| "awsRegion"
| "awsUseCrossRegionInference"
| "vertexProjectId"
@@ -64,7 +68,7 @@ type GlobalStateKey =
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "azureApiVersion"
| "includeStreamOptions"
| "openAiStreamingEnabled"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterUseMiddleOutTransform"
@@ -85,6 +89,7 @@ type GlobalStateKey =
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
glamaModels: "glama_models.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
}
@@ -388,6 +393,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
})
this.readGlamaModels().then((glamaModels) => {
if (glamaModels) {
this.postMessageToWebview({ type: "glamaModels", glamaModels })
}
})
this.refreshGlamaModels().then(async (glamaModels) => {
if (glamaModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration } = await this.getState()
if (apiConfiguration.glamaModelId) {
await this.updateGlobalState(
"glamaModelInfo",
glamaModels[apiConfiguration.glamaModelId],
)
await this.postStateToWebview()
}
}
})
break
case "newTask":
// Code that should run in response to the hello message command
@@ -406,6 +429,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiProvider,
apiModelId,
apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
@@ -425,7 +451,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
geminiApiKey,
openAiNativeApiKey,
azureApiVersion,
includeStreamOptions,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
@@ -434,6 +460,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.updateGlobalState("glamaModelId", glamaModelId)
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
await this.storeSecret("glamaApiKey", glamaApiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
@@ -454,7 +483,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", message.apiConfiguration.deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("includeStreamOptions", includeStreamOptions)
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
@@ -534,6 +563,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const vsCodeLmModels = await this.getVsCodeLmModels()
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
break
case "refreshGlamaModels":
await this.refreshGlamaModels()
break
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
break
@@ -710,6 +742,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,
@@ -851,6 +901,94 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return cacheDir
}
async readGlamaModels(): Promise<Record<string, ModelInfo> | undefined> {
const glamaModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
GlobalFileNames.glamaModels,
)
const fileExists = await fileExistsAtPath(glamaModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(glamaModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
async refreshGlamaModels() {
const glamaModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
GlobalFileNames.glamaModels,
)
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
/*
{
"added": "2024-12-24T15:12:49.324Z",
"capabilities": [
"adjustable_safety_settings",
"caching",
"code_execution",
"function_calling",
"json_mode",
"json_schema",
"system_instructions",
"tuning",
"input:audio",
"input:image",
"input:text",
"input:video",
"output:text"
],
"id": "google-vertex/gemini-1.5-flash-002",
"maxTokensInput": 1048576,
"maxTokensOutput": 8192,
"pricePerToken": {
"cacheRead": null,
"cacheWrite": null,
"input": "0.000000075",
"output": "0.0000003"
}
}
*/
if (response.data) {
const rawModels = response.data;
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.maxTokensOutput,
contextWindow: rawModel.maxTokensInput,
supportsImages: rawModel.capabilities?.includes("input:image"),
supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
supportsPromptCache: rawModel.capabilities?.includes("caching"),
inputPrice: parsePrice(rawModel.pricePerToken?.input),
outputPrice: parsePrice(rawModel.pricePerToken?.output),
description: undefined,
cacheWritesPrice: parsePrice(rawModel.pricePerToken?.cacheWrite),
cacheReadsPrice: parsePrice(rawModel.pricePerToken?.cacheRead),
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from Glama API")
}
await fs.writeFile(glamaModelsFilePath, JSON.stringify(models))
console.log("Glama models fetched and saved", models)
} catch (error) {
console.error("Error fetching Glama models:", error)
}
await this.postMessageToWebview({ type: "glamaModels", glamaModels: models })
return models
}
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
@@ -1174,6 +1312,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
storedApiProvider,
apiModelId,
apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
@@ -1194,7 +1335,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openAiNativeApiKey,
deepSeekApiKey,
azureApiVersion,
includeStreamOptions,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
@@ -1222,6 +1363,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("glamaApiKey") as Promise<string | undefined>,
this.getGlobalState("glamaModelId") as Promise<string | undefined>,
this.getGlobalState("glamaModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>,
@@ -1242,7 +1386,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
this.getGlobalState("includeStreamOptions") as Promise<boolean | undefined>,
this.getGlobalState("openAiStreamingEnabled") as Promise<boolean | undefined>,
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("openRouterUseMiddleOutTransform") as Promise<boolean | undefined>,
@@ -1287,6 +1431,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiProvider,
apiModelId,
apiKey,
glamaApiKey,
glamaModelId,
glamaModelInfo,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
@@ -1307,7 +1454,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openAiNativeApiKey,
deepSeekApiKey,
azureApiVersion,
includeStreamOptions,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
@@ -1426,6 +1573,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
const secretKeys: SecretKey[] = [
"apiKey",
"glamaApiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",

View File

@@ -1,109 +1,176 @@
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', () => {
const input = 'line 1\nline 2\nline 3';
const expected = '1 | line 1\n2 | line 2\n3 | line 3';
expect(addLineNumbers(input)).toBe(expected);
});
it('should add line numbers starting from 1 by default', () => {
const input = 'line 1\nline 2\nline 3';
const expected = '1 | line 1\n2 | line 2\n3 | line 3';
expect(addLineNumbers(input)).toBe(expected);
});
it('should add line numbers starting from specified line number', () => {
const input = 'line 1\nline 2\nline 3';
const expected = '10 | line 1\n11 | line 2\n12 | line 3';
expect(addLineNumbers(input, 10)).toBe(expected);
});
it('should add line numbers starting from specified line number', () => {
const input = 'line 1\nline 2\nline 3';
const expected = '10 | line 1\n11 | line 2\n12 | line 3';
expect(addLineNumbers(input, 10)).toBe(expected);
});
it('should handle empty content', () => {
expect(addLineNumbers('')).toBe('1 | ');
expect(addLineNumbers('', 5)).toBe('5 | ');
});
it('should handle empty content', () => {
expect(addLineNumbers('')).toBe('1 | ');
expect(addLineNumbers('', 5)).toBe('5 | ');
});
it('should handle single line content', () => {
expect(addLineNumbers('single line')).toBe('1 | single line');
expect(addLineNumbers('single line', 42)).toBe('42 | single line');
});
it('should handle single line content', () => {
expect(addLineNumbers('single line')).toBe('1 | single line');
expect(addLineNumbers('single line', 42)).toBe('42 | single line');
});
it('should pad line numbers based on the highest line number', () => {
const input = 'line 1\nline 2';
// When starting from 99, highest line will be 100, so needs 3 spaces padding
const expected = ' 99 | line 1\n100 | line 2';
expect(addLineNumbers(input, 99)).toBe(expected);
});
it('should pad line numbers based on the highest line number', () => {
const input = 'line 1\nline 2';
// When starting from 99, highest line will be 100, so needs 3 spaces padding
const expected = ' 99 | line 1\n100 | line 2';
expect(addLineNumbers(input, 99)).toBe(expected);
});
});
describe('everyLineHasLineNumbers', () => {
it('should return true for content with line numbers', () => {
const input = '1 | line one\n2 | line two\n3 | line three';
expect(everyLineHasLineNumbers(input)).toBe(true);
});
it('should return true for content with line numbers', () => {
const input = '1 | line one\n2 | line two\n3 | line three';
expect(everyLineHasLineNumbers(input)).toBe(true);
});
it('should return true for content with padded line numbers', () => {
const input = ' 1 | line one\n 2 | line two\n 3 | line three';
expect(everyLineHasLineNumbers(input)).toBe(true);
});
it('should return true for content with padded line numbers', () => {
const input = ' 1 | line one\n 2 | line two\n 3 | line three';
expect(everyLineHasLineNumbers(input)).toBe(true);
});
it('should return false for content without line numbers', () => {
const input = 'line one\nline two\nline three';
expect(everyLineHasLineNumbers(input)).toBe(false);
});
it('should return false for content without line numbers', () => {
const input = 'line one\nline two\nline three';
expect(everyLineHasLineNumbers(input)).toBe(false);
});
it('should return false for mixed content', () => {
const input = '1 | line one\nline two\n3 | line three';
expect(everyLineHasLineNumbers(input)).toBe(false);
});
it('should return false for mixed content', () => {
const input = '1 | line one\nline two\n3 | line three';
expect(everyLineHasLineNumbers(input)).toBe(false);
});
it('should handle empty content', () => {
expect(everyLineHasLineNumbers('')).toBe(false);
});
it('should handle empty content', () => {
expect(everyLineHasLineNumbers('')).toBe(false);
});
it('should return false for content with pipe but no line numbers', () => {
const input = 'a | b\nc | d';
expect(everyLineHasLineNumbers(input)).toBe(false);
});
it('should return false for content with pipe but no line numbers', () => {
const input = 'a | b\nc | d';
expect(everyLineHasLineNumbers(input)).toBe(false);
});
});
describe('stripLineNumbers', () => {
it('should strip line numbers from content', () => {
const input = '1 | line one\n2 | line two\n3 | line three';
const expected = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should strip line numbers from content', () => {
const input = '1 | line one\n2 | line two\n3 | line three';
const expected = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should strip padded line numbers', () => {
const input = ' 1 | line one\n 2 | line two\n 3 | line three';
const expected = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should strip padded line numbers', () => {
const input = ' 1 | line one\n 2 | line two\n 3 | line three';
const expected = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should handle content without line numbers', () => {
const input = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(input);
});
it('should handle content without line numbers', () => {
const input = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(input);
});
it('should handle empty content', () => {
expect(stripLineNumbers('')).toBe('');
});
it('should handle empty content', () => {
expect(stripLineNumbers('')).toBe('');
});
it('should preserve content with pipe but no line numbers', () => {
const input = 'a | b\nc | d';
expect(stripLineNumbers(input)).toBe(input);
});
it('should preserve content with pipe but no line numbers', () => {
const input = 'a | b\nc | d';
expect(stripLineNumbers(input)).toBe(input);
});
it('should handle windows-style line endings', () => {
const input = '1 | line one\r\n2 | line two\r\n3 | line three';
const expected = 'line one\r\nline two\r\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should handle windows-style line endings', () => {
const input = '1 | line one\r\n2 | line two\r\n3 | line three';
const expected = 'line one\r\nline two\r\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should handle content with varying line number widths', () => {
const input = ' 1 | line one\n 10 | line two\n100 | line three';
const expected = 'line one\nline two\nline three';
expect(stripLineNumbers(input)).toBe(expected);
});
it('should handle content with varying line number widths', () => {
const input = ' 1 | line one\n 10 | line two\n100 | line three';
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

@@ -87,4 +87,38 @@ export function stripLineNumbers(content: string): string {
// Join back with original line endings
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,26 +3,31 @@
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 {
type:
| "action"
| "state"
| "selectedImages"
| "ollamaModels"
| "lmStudioModels"
| "vsCodeLmModels"
| "vsCodeLmApiAvailable"
| "requestVsCodeLmModels"
| "theme"
| "workspaceUpdated"
| "invoke"
| "partialMessage"
| "openRouterModels"
| "openAiModels"
| "mcpServers"
| "enhancedPrompt"
type:
| "action"
| "state"
| "selectedImages"
| "ollamaModels"
| "lmStudioModels"
| "vsCodeLmModels"
| "vsCodeLmApiAvailable"
| "requestVsCodeLmModels"
| "theme"
| "workspaceUpdated"
| "invoke"
| "partialMessage"
| "glamaModels"
| "openRouterModels"
| "openAiModels"
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
text?: string
action?:
| "chatButtonClicked"
@@ -38,9 +43,11 @@ type:
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
filePaths?: string[]
partialMessage?: ClineMessage
glamaModels?: Record<string, ModelInfo>
openRouterModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
commits?: GitCommit[]
}
export interface ExtensionState {

View File

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

View File

@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
export type ApiProvider =
| "anthropic"
| "glama"
| "openrouter"
| "bedrock"
| "vertex"
@@ -18,6 +19,9 @@ export interface ApiHandlerOptions {
apiKey?: string // anthropic
anthropicBaseUrl?: string
vsCodeLmModelSelector?: vscode.LanguageModelChatSelector
glamaModelId?: string
glamaModelInfo?: ModelInfo
glamaApiKey?: string
openRouterApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
@@ -41,7 +45,7 @@ export interface ApiHandlerOptions {
openAiNativeApiKey?: string
azureApiVersion?: string
openRouterUseMiddleOutTransform?: boolean
includeStreamOptions?: boolean
openAiStreamingEnabled?: boolean
setAzureApiVersion?: boolean
deepSeekBaseUrl?: string
deepSeekApiKey?: string
@@ -314,6 +318,23 @@ export const bedrockModels = {
},
} as const satisfies Record<string, ModelInfo>
// Glama
// https://glama.ai/models
export const glamaDefaultModelId = "anthropic/claude-3-5-sonnet"
export const glamaDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description:
"The new Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: New Sonnet scores ~49% on SWE-Bench Verified, higher than the last best score, and without any fancy prompt scaffolding\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal\n\n_This is a faster endpoint, made available in collaboration with Anthropic, that is self-moderated: response moderation happens on the provider's side instead of OpenRouter's. For requests that pass moderation, it's identical to the [Standard](/anthropic/claude-3.5-sonnet) variant._",
}
// OpenRouter
// https://openrouter.ai/models?order=newest&supported_parameters=tools
export const openRouterDefaultModelId = "anthropic/claude-3.5-sonnet:beta" // will always exist in openRouterModels

View File

@@ -7,42 +7,79 @@ Mention regex:
- **Regex Breakdown**:
- `/@`:
- **@**: The mention must start with the '@' symbol.
- **@**: The mention must start with the '@' symbol.
- `((?:\/|\w+:\/\/)[^\s]+?|problems\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.
- `\/`:
- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
- `|`: Logical OR.
- `\w+:\/\/`:
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
- `[^\s]+?`:
- **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
- `|`: Logical OR.
- `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').
- `((?:\/|\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.
- `\/`:
- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
- `|`: Logical OR.
- `\w+:\/\/`:
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
- `[^\s]+?`:
- **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
- `|`: Logical OR.
- `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.
- `[.,;:!?]?`:
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
- `(?=[\s\r\n]|$)`:
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
- `[.,;:!?]?`:
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
- `(?=[\s\r\n]|$)`:
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
- **Summary**:
- The regex effectively matches:
- 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'.
- 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)}`
}
}