Add terminal output and diagnostics to relevant details

This commit is contained in:
Saoud Rizwan
2024-09-08 18:06:52 -04:00
parent 076acf736e
commit a6c64bea8f
14 changed files with 320 additions and 455 deletions

View File

@@ -21,7 +21,7 @@ import { getApiMetrics } from "./shared/getApiMetrics"
import { HistoryItem } from "./shared/HistoryItem"
import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAskResponse } from "./shared/WebviewMessage"
import { findLast, findLastIndex } from "./utils"
import { findLast, findLastIndex, formatContentBlockToMarkdown } from "./utils"
import { truncateHalfConversation } from "./utils/context-management"
import { regexSearchFiles } from "./utils/ripgrep"
import { extractTextFromFile } from "./utils/extract-text"
@@ -281,7 +281,7 @@ export class ClaudeDev {
) {
this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager(provider.context)
this.terminalManager = new TerminalManager()
this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -445,31 +445,6 @@ export class ClaudeDev {
await this.providerRef.deref()?.postStateToWebview()
}
private formatImagesIntoBlocks(images?: string[]): Anthropic.ImageBlockParam[] {
return images
? images.map((dataUrl) => {
// data:image/png;base64,base64string
const [rest, base64] = dataUrl.split(",")
const mimeType = rest.split(":")[1].split(";")[0]
return {
type: "image",
source: { type: "base64", media_type: mimeType, data: base64 },
} as Anthropic.ImageBlockParam
})
: []
}
private formatIntoToolResponse(text: string, images?: string[]): ToolResponse {
if (images && images.length > 0) {
const textBlock: Anthropic.TextBlockParam = { type: "text", text }
const imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images)
// Placing images after text leads to better results
return [textBlock, ...imageBlocks]
} else {
return text
}
}
private async startTask(task?: string, images?: string[]): Promise<void> {
// conversationHistory (for API) and claudeMessages (for webview) need to be in sync
// if the extension process were killed, then on restart the claudeMessages might not be empty, so we need to set it to [] when we create a new ClaudeDev client (otherwise webview would show stale messages from previous session)
@@ -486,21 +461,19 @@ export class ClaudeDev {
await this.say(
"api_req_started",
JSON.stringify({
request: this.api.createUserReadableRequest([
{
type: "text",
text: `${taskText}\n\n<potentially_relevant_details>(see getPotentiallyRelevantDetails in src/ClaudeDev.ts)</potentially_relevant_details>`,
},
...imageBlocks,
]),
request: `${taskText}\n\n<potentially_relevant_details>\nLoading...\n</potentially_relevant_details>`,
})
)
this.shouldSkipNextApiReqStartedMessage = true
this.getPotentiallyRelevantDetails(true).then(async (verboseDetails) => {
this.getInitialDetails().then(async (initialDetails) => {
const lastApiReqIndex = findLastIndex(this.claudeMessages, (m) => m.say === "api_req_started")
this.claudeMessages[lastApiReqIndex].text = JSON.stringify({ request: `${taskText}\n\n${initialDetails}` })
await this.saveClaudeMessages()
await this.providerRef.deref()?.postStateToWebview()
await this.initiateTaskLoop([
{
type: "text",
text: `${taskText}\n\n${verboseDetails}`, // cannot be sent with system prompt since it's cached and these details can change
text: `${taskText}\n\n${initialDetails}`, // cannot be sent with system prompt since it's cached and these details can change
},
...imageBlocks,
])
@@ -687,8 +660,7 @@ export class ClaudeDev {
: "") +
(newUserContentText
? `\n\nNew instructions for task continuation:\n<user_message>\n${newUserContentText}\n</user_message>\n`
: "") +
`\n\n${await this.getPotentiallyRelevantDetails()}`
: "")
const newUserContentImages = newUserContent.filter((block) => block.type === "image")
const combinedModifiedOldUserContentWithNewUserContent: UserContent = (
@@ -781,21 +753,12 @@ export class ClaudeDev {
async writeToFile(relPath?: string, newContent?: string): Promise<ToolResponse> {
if (relPath === undefined) {
await this.say(
"error",
"Claude tried to use write_to_file without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("write_to_file", "path")
}
if (newContent === undefined) {
await this.say(
"error",
`Claude tried to use write_to_file for '${relPath}' without value for required parameter 'content'. This is likely due to output token limits. Retrying...`
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'content'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("write_to_file", "content", relPath)
}
this.consecutiveMistakeCount = 0
try {
@@ -991,9 +954,9 @@ export class ClaudeDev {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
const editedContent = updatedDocument.getText()
@@ -1070,9 +1033,11 @@ export class ClaudeDev {
diff: this.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent),
} as ClaudeSayTool)
)
return `The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath}. Note this does not mean you need to re-write the file with the user's changes, they have already been applied to the file.`
return this.formatToolResult(
`The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath}. Note this does not mean you need to re-write the file with the user's changes, they have already been applied to the file.`
)
} else {
return `The content was successfully saved to ${relPath}.`
return this.formatToolResult(`The content was successfully saved to ${relPath}.`)
}
} catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
@@ -1080,7 +1045,7 @@ export class ClaudeDev {
"error",
`Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString
return await this.formatToolError(errorString)
}
}
@@ -1152,12 +1117,8 @@ export class ClaudeDev {
async readFile(relPath?: string): Promise<ToolResponse> {
if (relPath === undefined) {
await this.say(
"error",
"Claude tried to use read_file without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("read_file", "path")
}
this.consecutiveMistakeCount = 0
try {
@@ -1176,9 +1137,9 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
@@ -1189,18 +1150,14 @@ export class ClaudeDev {
"error",
`Error reading file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString
return await this.formatToolError(errorString)
}
}
async listFiles(relDirPath?: string, recursiveRaw?: string): Promise<ToolResponse> {
if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use list_files without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("list_files", "path")
}
this.consecutiveMistakeCount = 0
try {
@@ -1221,13 +1178,13 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
return result
return this.formatToolResult(result)
} catch (error) {
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
await this.say(
@@ -1236,7 +1193,7 @@ export class ClaudeDev {
error.message ?? JSON.stringify(serializeError(error), null, 2)
}`
)
return errorString
return await this.formatToolError(errorString)
}
}
@@ -1301,12 +1258,8 @@ export class ClaudeDev {
async listCodeDefinitionNames(relDirPath?: string): Promise<ToolResponse> {
if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use list_code_definition_names without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("list_code_definition_names", "path")
}
this.consecutiveMistakeCount = 0
try {
@@ -1325,13 +1278,13 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
return result
return this.formatToolResult(result)
} catch (error) {
const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}`
await this.say(
@@ -1340,26 +1293,18 @@ export class ClaudeDev {
error.message ?? JSON.stringify(serializeError(error), null, 2)
}`
)
return errorString
return await this.formatToolError(errorString)
}
}
async searchFiles(relDirPath: string, regex: string, filePattern?: string): Promise<ToolResponse> {
if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use search_files without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'path'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("search_files", "path")
}
if (regex === undefined) {
await this.say(
"error",
`Claude tried to use search_files without value for required parameter 'regex'. Retrying...`
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'regex'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("search_files", "regex", relDirPath)
}
this.consecutiveMistakeCount = 0
try {
@@ -1381,40 +1326,36 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
}
return results
return this.formatToolResult(results)
} catch (error) {
const errorString = `Error searching files: ${JSON.stringify(serializeError(error))}`
await this.say(
"error",
`Error searching files:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString
return await this.formatToolError(errorString)
}
}
async executeCommand(command?: string, returnEmptyStringOnSuccess: boolean = false): Promise<ToolResponse> {
if (command === undefined) {
await this.say(
"error",
"Claude tried to use execute_command without value for required parameter 'command'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'command'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("execute_command", "command")
}
this.consecutiveMistakeCount = 0
const { response, text, images } = await this.ask("command", command)
if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images)
}
return "The user denied this operation."
return await this.formatToolDenied()
}
try {
@@ -1439,7 +1380,7 @@ export class ClaudeDev {
let result = ""
process.on("line", (line) => {
console.log("sending line from here", line)
console.log("New line from process:", line)
result += line
sendCommandOutput(line)
})
@@ -1455,10 +1396,8 @@ export class ClaudeDev {
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images)
return this.formatIntoToolResponse(
`Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n<feedback>\n${
userFeedback.text
}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
return this.formatToolResponseWithImages(
`Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images
)
}
@@ -1467,39 +1406,31 @@ export class ClaudeDev {
if (returnEmptyStringOnSuccess) {
return ""
}
return `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`
return await this.formatToolResult(`Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`)
} catch (error) {
let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2)
const errorString = `Error executing command:\n${errorMessage}`
await this.say("error", `Error executing command:\n${errorMessage}`)
return errorString
return await this.formatToolError(errorString)
}
}
async askFollowupQuestion(question?: string): Promise<ToolResponse> {
if (question === undefined) {
await this.say(
"error",
"Claude tried to use ask_followup_question without value for required parameter 'question'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'question'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("ask_followup_question", "question")
}
this.consecutiveMistakeCount = 0
const { text, images } = await this.ask("followup", question)
await this.say("user_feedback", text ?? "", images)
return this.formatIntoToolResponse(`<answer>\n${text}\n</answer>`, images)
return this.formatToolResponseWithImages(`<answer>\n${text}\n</answer>`, images)
}
async attemptCompletion(result?: string, command?: string): Promise<ToolResponse> {
// result is required, command is optional
if (result === undefined) {
await this.say(
"error",
"Claude tried to use attempt_completion without value for required parameter 'result'. Retrying..."
)
this.consecutiveMistakeCount++
return "Error: Missing value for required parameter 'result'. Please retry with complete response."
return await this.sayAndCreateMissingParamError("attempt_completion", "result")
}
this.consecutiveMistakeCount = 0
let resultToSend = result
@@ -1518,8 +1449,8 @@ export class ClaudeDev {
return "" // signals to recursive loop to stop (for now this never happens since yesButtonTapped will trigger a new task)
}
await this.say("user_feedback", text ?? "", images)
return this.formatIntoToolResponse(
`The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n<feedback>\n${text}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
return this.formatToolResponseWithImages(
`The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n<feedback>\n${text}\n</feedback>`,
images
)
}
@@ -1600,7 +1531,7 @@ ${this.customInstructions.trim()}
...[
{
type: "text",
text: `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n<feedback>\n${text}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
text: `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n<feedback>\n${text}\n</feedback>`,
} as Anthropic.Messages.TextBlockParam,
...this.formatImagesIntoBlocks(images),
]
@@ -1609,15 +1540,15 @@ ${this.customInstructions.trim()}
this.consecutiveMistakeCount = 0
}
// add potentially relevant details as its own text block, separate from tool results
userContent.push({ type: "text", text: await this.getPotentiallyRelevantDetails() })
await this.addToApiConversationHistory({ role: "user", content: userContent })
if (!this.shouldSkipNextApiReqStartedMessage) {
await this.say(
"api_req_started",
// what the user sees in the webview
JSON.stringify({
request: this.api.createUserReadableRequest(userContent),
})
JSON.stringify({ request: userContent.map(formatContentBlockToMarkdown).join("\n\n") })
)
} else {
this.shouldSkipNextApiReqStartedMessage = false
@@ -1748,9 +1679,50 @@ ${this.customInstructions.trim()}
}
}
// Prompts
// Formatting responses to Claude
async getPotentiallyRelevantDetails(verbose: boolean = false) {
private formatImagesIntoBlocks(images?: string[]): Anthropic.ImageBlockParam[] {
return images
? images.map((dataUrl) => {
// data:image/png;base64,base64string
const [rest, base64] = dataUrl.split(",")
const mimeType = rest.split(":")[1].split(";")[0]
return {
type: "image",
source: { type: "base64", media_type: mimeType, data: base64 },
} as Anthropic.ImageBlockParam
})
: []
}
private formatToolResponseWithImages(text: string, images?: string[]): ToolResponse {
if (images && images.length > 0) {
const textBlock: Anthropic.TextBlockParam = { type: "text", text }
const imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images)
// Placing images after text leads to better results
return [textBlock, ...imageBlocks]
} else {
return text
}
}
async getInitialDetails() {
let details = "<potentially_relevant_details>"
const isDesktop = cwd === path.join(os.homedir(), "Desktop")
const files = await listFiles(cwd, !isDesktop)
const result = this.formatFilesList(cwd, files)
details += `\n# Current Working Directory ('${cwd}') File Structure:${
isDesktop
? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)"
: ""
}\n${result}\n`
details += "</potentially_relevant_details>"
return details
}
async getPotentiallyRelevantDetails() {
let details = `<potentially_relevant_details>
# VSCode Visible Files:
${
@@ -1769,25 +1741,76 @@ ${
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath))
.join("\n") || "(No tabs open)"
}
`
}`
if (verbose) {
const isDesktop = cwd === path.join(os.homedir(), "Desktop")
const files = await listFiles(cwd, !isDesktop)
const result = this.formatFilesList(cwd, files)
details += `\n# Current Working Directory ('${cwd}') File Structure:${
isDesktop
? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)"
: ""
}:\n${result}\n`
const busyTerminals = this.terminalManager.getBusyTerminals()
if (busyTerminals.length > 0) {
details += "\n\n# Active Terminals:"
for (const busyTerminal of busyTerminals) {
details += `\n## Original command:\n${busyTerminal.lastCommand}`
const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id)
if (newOutput) {
details += `\n## New output since last check:\n${newOutput}`
}
}
}
details += "</potentially_relevant_details>"
// Get diagnostics for all open files in the workspace
const diagnostics = vscode.languages.getDiagnostics()
const relevantDiagnostics = diagnostics.filter(([_, fileDiagnostics]) =>
fileDiagnostics.some(
(d) =>
d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning
)
)
if (relevantDiagnostics.length > 0) {
details += "\n\n# Workspace Diagnostics:"
for (const [uri, fileDiagnostics] of relevantDiagnostics) {
const relativePath = path.relative(cwd, uri.fsPath)
details += `\n## ${relativePath}:`
for (const diagnostic of fileDiagnostics) {
if (
diagnostic.severity === vscode.DiagnosticSeverity.Error ||
diagnostic.severity === vscode.DiagnosticSeverity.Warning
) {
let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning"
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
details += `\n- [${severity}] Line ${line}: ${diagnostic.message}`
}
}
}
}
details += "\n</potentially_relevant_details>"
return details
}
async formatGenericToolFeedback(feedback?: string) {
return `The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`
async formatToolDeniedFeedback(feedback?: string) {
return `The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>`
}
async formatToolDenied() {
return `The user denied this operation.`
}
async formatToolResult(result: string) {
return result // the successful result of the tool should never be manipulated, if we need to add details it should be as a separate user text block
}
async formatToolError(error?: string) {
return `The tool execution failed with the following error:\n<error>\n${error}\n</error>`
}
async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) {
await this.say(
"error",
`Claude tried to use ${toolName}${
relPath ? ` for '${relPath}'` : ""
} without value for required parameter '${paramName}'. Retrying...`
)
return await this.formatToolError(
`Missing value for required parameter '${paramName}'. Please retry with complete response.`
)
}
}

View File

@@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../shared/api"
export class AnthropicHandler implements ApiHandler {
@@ -99,24 +99,6 @@ export class AnthropicHandler implements ApiHandler {
}
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: AnthropicModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in anthropicModels) {

View File

@@ -1,6 +1,6 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../shared/api"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
@@ -39,24 +39,6 @@ export class AwsBedrockHandler implements ApiHandler {
return { message }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: BedrockModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in bedrockModels) {

View File

@@ -19,15 +19,6 @@ export interface ApiHandler {
tools: Anthropic.Messages.Tool[]
): Promise<ApiHandlerMessageResponse>
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any
getModel(): { id: string; info: ModelInfo }
}
@@ -50,31 +41,3 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new AnthropicHandler(options)
}
}
export function withoutImageData(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
> {
return userContent.map((part) => {
if (part.type === "image") {
return { ...part, source: { ...part.source, data: "..." } }
} else if (part.type === "tool_result" && typeof part.content !== "string") {
return {
...part,
content: part.content?.map((contentPart) => {
if (contentPart.type === "image") {
return { ...contentPart, source: { ...contentPart.source, data: "..." } }
}
return contentPart
}),
}
}
return part
})
}

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api"
import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format"
@@ -48,23 +48,6 @@ export class OllamaHandler implements ApiHandler {
return { message: anthropicMessage }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.options.ollamaModelId ?? "",
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: "auto",
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.ollamaModelId ?? "",

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api"
import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format"
@@ -48,23 +48,6 @@ export class OpenAiHandler implements ApiHandler {
return { message: anthropicMessage }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.options.openAiModelId ?? "",
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: "auto",
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.openAiModelId ?? "",

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import {
ApiHandlerOptions,
ModelInfo,
@@ -177,24 +177,6 @@ export class OpenRouterHandler implements ApiHandler {
return completion
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: "auto",
}
}
getModel(): { id: OpenRouterModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in openRouterModels) {

View File

@@ -1,6 +1,6 @@
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../shared/api"
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
@@ -33,24 +33,6 @@ export class VertexHandler implements ApiHandler {
return { message }
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: VertexModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in vertexModels) {

View File

@@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { EventEmitter } from "events"
import delay from "delay"
import pWaitFor from "p-wait-for"
/*
TerminalManager:
@@ -21,6 +21,14 @@ Enables flexible command execution:
- Continue execution in background
- Retrieve missed output later
Notes:
- it turns out some shellIntegration APIs are available on cursor, although not on older versions of vscode
- "By default, the shell integration script should automatically activate on supported shells launched from VS Code."
Supported shells:
Linux/macOS: bash, fish, pwsh, zsh
Windows: pwsh
Example:
const terminalManager = new TerminalManager(context);
@@ -41,77 +49,75 @@ process.continue();
// Later, if you need to get the unretrieved output:
const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId);
console.log('Unretrieved output:', unretrievedOutput);
Resources:
- https://github.com/microsoft/vscode/issues/226655
- https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api
- https://code.visualstudio.com/docs/terminal/shell-integration
- https://code.visualstudio.com/api/references/vscode-api#Terminal
- https://github.com/microsoft/vscode-extension-samples/blob/main/terminal-sample/src/extension.ts
- https://github.com/microsoft/vscode-extension-samples/blob/main/shell-integration-sample/src/extension.ts
*/
export class TerminalManager {
private terminals: TerminalInfo[] = []
private processes: Map<number, TerminalProcess> = new Map()
private context: vscode.ExtensionContext
private nextTerminalId = 1
constructor(context: vscode.ExtensionContext) {
this.context = context
this.setupListeners()
}
private setupListeners() {
// todo: make sure we do this check everywhere we use the new terminal APIs
if (hasShellIntegrationApis()) {
this.context.subscriptions.push(
vscode.window.onDidOpenTerminal(this.handleOpenTerminal.bind(this)),
vscode.window.onDidCloseTerminal(this.handleClosedTerminal.bind(this)),
vscode.window.onDidChangeTerminalShellIntegration(this.handleShellIntegrationChange.bind(this)),
vscode.window.onDidStartTerminalShellExecution(this.handleShellExecutionStart.bind(this)),
vscode.window.onDidEndTerminalShellExecution(this.handleShellExecutionEnd.bind(this))
)
}
}
runCommand(terminalInfo: TerminalInfo, command: string, cwd: string): TerminalProcessResultPromise {
terminalInfo.busy = true
terminalInfo.lastCommand = command
const process = new TerminalProcess(terminalInfo, command)
const process = new TerminalProcess()
this.processes.set(terminalInfo.id, process)
process.once("completed", () => {
console.log(`completed received for terminal ${terminalInfo.id}`)
terminalInfo.busy = false
})
const promise = new Promise<void>((resolve, reject) => {
process.once(CONTINUE_EVENT, () => {
console.log("2")
process.once("continue", () => {
console.log(`continue received for terminal ${terminalInfo.id}`)
resolve()
})
process.once("error", reject)
process.once("error", (error) => {
console.error(`Error in terminal ${terminalInfo.id}:`, error)
reject(error)
})
})
// if shell integration is already active, run the command immediately
if (terminalInfo.terminal.shellIntegration) {
console.log(`Shell integration active for terminal ${terminalInfo.id}, running command immediately`)
process.waitForShellIntegration = false
process.run()
}
if (hasShellIntegrationApis()) {
// Fallback to sendText if there is no shell integration within 3 seconds of launching (could be because the user is not running one of the supported shells)
setTimeout(() => {
if (!terminalInfo.terminal.shellIntegration) {
process.waitForShellIntegration = false
process.run()
// Without shell integration, we can't know when the command has finished or what the
// exit code was.
}
}, 3000)
process.run(terminalInfo.terminal, command)
} else {
// User doesn't have shell integration API available, run command the old way
process.waitForShellIntegration = false
process.run()
console.log(`Waiting for shell integration for terminal ${terminalInfo.id}`)
// docs recommend waiting 3s for shell integration to activate
pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => {
console.log(
`Shell integration ${
terminalInfo.terminal.shellIntegration ? "activated" : "not activated"
} for terminal ${terminalInfo.id}`
)
const existingProcess = this.processes.get(terminalInfo.id)
if (existingProcess && existingProcess.waitForShellIntegration) {
existingProcess.waitForShellIntegration = false
existingProcess.run(terminalInfo.terminal, command)
}
})
}
// Merge the process and promise
return mergePromise(process, promise)
}
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
const availableTerminal = this.terminals.find((t) => {
if (t.busy) {
// it seems even if you close the terminal, it can still be reused
const isDisposed = !t.terminal || t.terminal.exitStatus // The exit status of the terminal will be undefined while the terminal is active.
console.log(`Terminal ${t.id} isDisposed:`, isDisposed)
if (t.busy || isDisposed) {
return false
}
const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal
@@ -121,7 +127,7 @@ export class TerminalManager {
return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath
})
if (availableTerminal) {
console.log("reusing terminal", availableTerminal.id)
console.log("Reusing terminal", availableTerminal.id)
return availableTerminal
}
@@ -140,63 +146,10 @@ export class TerminalManager {
return newTerminalInfo
}
private handleOpenTerminal(terminal: vscode.Terminal) {
console.log(`Terminal opened: ${terminal.name}`)
}
private handleClosedTerminal(terminal: vscode.Terminal) {
const index = this.terminals.findIndex((t) => t.terminal === terminal)
if (index !== -1) {
const terminalInfo = this.terminals[index]
this.terminals.splice(index, 1)
this.processes.delete(terminalInfo.id)
}
console.log(`Terminal closed: ${terminal.name}`)
}
private handleShellIntegrationChange(e: vscode.TerminalShellIntegrationChangeEvent) {
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
if (terminalInfo) {
const process = this.processes.get(terminalInfo.id)
if (process && process.waitForShellIntegration) {
process.waitForShellIntegration = false
process.run()
}
console.log(`Shell integration activated for terminal: ${e.terminal.name}`)
}
}
private handleShellExecutionStart(e: vscode.TerminalShellExecutionStartEvent) {
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
if (terminalInfo) {
terminalInfo.busy = true
terminalInfo.lastCommand = e.execution.commandLine.value
console.log(`Command started in terminal ${terminalInfo.id}: ${terminalInfo.lastCommand}`)
}
}
private handleShellExecutionEnd(e: vscode.TerminalShellExecutionEndEvent) {
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
if (terminalInfo) {
this.handleCommandCompletion(terminalInfo, e.exitCode)
}
}
private handleCommandCompletion(terminalInfo: TerminalInfo, exitCode?: number | undefined) {
terminalInfo.busy = false
console.log(
`Command "${terminalInfo.lastCommand}" in terminal ${terminalInfo.id} completed with exit code: ${exitCode}`
)
}
getBusyTerminals(): { id: number; lastCommand: string }[] {
return this.terminals.filter((t) => t.busy).map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
}
hasBusyTerminals(): boolean {
return this.terminals.some((t) => t.busy)
}
getUnretrievedOutput(terminalId: number): string {
const process = this.processes.get(terminalId)
if (!process) {
@@ -206,19 +159,14 @@ export class TerminalManager {
}
disposeAll() {
for (const info of this.terminals) {
info.terminal.dispose() // todo do we want to do this? test with tab view closing it
}
// for (const info of this.terminals) {
// //info.terminal.dispose() // dont want to dispose terminals when task is aborted
// }
this.terminals = []
this.processes.clear()
}
}
function hasShellIntegrationApis(): boolean {
const [major, minor] = vscode.version.split(".").map(Number)
return major > 1 || (major === 1 && minor >= 93)
}
interface TerminalInfo {
terminal: vscode.Terminal
busy: boolean
@@ -226,54 +174,59 @@ interface TerminalInfo {
id: number
}
const CONTINUE_EVENT = "CONTINUE_EVENT"
interface TerminalProcessEvents {
line: [line: string]
continue: []
completed: []
error: [error: Error]
}
export class TerminalProcess extends EventEmitter {
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
waitForShellIntegration: boolean = true
private isListening: boolean = true
private buffer: string = ""
private execution?: vscode.TerminalShellExecution
private stream?: AsyncIterable<string>
private fullOutput: string = ""
private lastRetrievedIndex: number = 0
constructor(public terminalInfo: TerminalInfo, private command: string) {
super()
}
// constructor() {
// super()
async run() {
if (this.terminalInfo.terminal.shellIntegration) {
this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command)
this.stream = this.execution.read()
async run(terminal: vscode.Terminal, command: string) {
if (terminal.shellIntegration) {
console.log(`Shell integration available for terminal`)
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors
let isFirstChunk = true // ignore first chunk since it's vscode shell integration marker
for await (const data of this.stream) {
console.log("data", data)
if (!isFirstChunk) {
for await (const data of stream) {
console.log(`Received data chunk for terminal:`, data)
this.fullOutput += data
if (this.isListening) {
console.log(`Emitting data for terminal`)
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
} else {
isFirstChunk = false
}
}
// Emit any remaining content in the buffer
if (this.buffer && this.isListening) {
console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
this.emit("line", this.buffer.trim())
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
this.emit(CONTINUE_EVENT)
console.log(`Command execution completed for terminal`)
this.emit("continue")
this.emit("completed")
} else {
this.terminalInfo.terminal.sendText(this.command, true)
console.log(`Shell integration not available for terminal, falling back to sendText`)
terminal.sendText(command, true)
// For terminals without shell integration, we can't know when the command completes
// So we'll just emit the continue event after a delay
setTimeout(() => {
this.emit(CONTINUE_EVENT)
console.log(`Emitting continue after delay for terminal`)
this.emit("continue")
// can't emit completed since we don't if the command actually completed, it could still be running server
}, 2000) // Adjust this delay as needed
}
}
@@ -294,13 +247,17 @@ export class TerminalProcess extends EventEmitter {
}
continue() {
this.isListening = false
this.removeAllListeners("line")
this.emit(CONTINUE_EVENT)
// Emit any remaining content in the buffer
if (this.buffer && this.isListening) {
console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
this.emit("line", this.buffer.trim())
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
isStillListening() {
return this.isListening
this.isListening = false
this.removeAllListeners("line")
this.emit("continue")
}
getUnretrievedOutput(): string {

View File

@@ -4,7 +4,7 @@ import os from "os"
import * as path from "path"
import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser"
export const LIST_FILES_LIMIT = 500
export const LIST_FILES_LIMIT = 200
// TODO: implement caching behavior to avoid having to keep analyzing project for new tasks.
export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise<string> {

View File

@@ -42,7 +42,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
}
}
function formatContentBlockToMarkdown(
export function formatContentBlockToMarkdown(
block:
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam

View File

@@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown"
import { ClaudeMessage, ClaudeSayTool } from "../../../src/shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences"
import CodeAccordian from "./CodeAccordian"
import CodeBlock from "./CodeBlock"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
import Thumbnails from "./Thumbnails"
interface ChatRowProps {
@@ -352,8 +352,8 @@ const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessa
{isExpanded && (
<div style={{ marginTop: "10px" }}>
<CodeAccordian
code={JSON.stringify(JSON.parse(message.text || "{}").request, null, 2)}
language="json"
code={JSON.parse(message.text || "{}").request}
language="markdown"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
@@ -516,19 +516,32 @@ const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessa
borderRadius: 3,
border: "1px solid var(--vscode-sideBar-border)",
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<CodeBlock source={`${"```"}shell\n${command}\n${"```"}`} />
</div>
<CodeBlock source={`${"```"}shell\n${command}\n${"```"}`} forceWrap={true} />
{output.length > 0 && (
<div style={{ width: "100%" }}>
<div
onClick={onToggleExpand}
style={{
borderRadius: 3,
border: "1px solid var(--vscode-sideBar-border)",
overflow: "hidden",
display: "flex",
alignItems: "center",
gap: "4px",
width: "100%",
justifyContent: "flex-start",
cursor: "pointer",
padding: `2px 8px ${isExpanded ? 0 : 8}px 8px`,
}}>
<CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />
<span
className={`codicon codicon-chevron-${
isExpanded ? "down" : "right"
}`}></span>
<span style={{ fontSize: "0.8em" }}>Command Output</span>
</div>
{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
</div>
)}
</div>
</>
)
case "completion_result":

View File

@@ -5,7 +5,7 @@ import styled from "styled-components"
import { visit } from "unist-util-visit"
import { useExtensionState } from "../context/ExtensionStateContext"
const BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
/*
overflowX: auto + inner div with padding results in an issue where the top/left/bottom padding renders but the right padding inside does not count as overflow as the width of the element is not exceeded. Once the inner div is outside the boundaries of the parent it counts as overflow.
@@ -15,12 +15,27 @@ this fixes the issue of right padding clipped off
minWidth: "max-content",
*/
const StyledMarkdown = styled.div`
interface CodeBlockProps {
source?: string
forceWrap?: boolean
}
const StyledMarkdown = styled.div<{ forceWrap: boolean }>`
${({ forceWrap }) =>
forceWrap &&
`
pre, code {
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: anywhere;
}
`}
pre {
background-color: ${BG_COLOR};
background-color: ${CODE_BLOCK_BG_COLOR};
border-radius: 5px;
margin: 0;
min-width: max-content;
min-width: ${({ forceWrap }) => (forceWrap ? "auto" : "max-content")};
padding: 10px 10px;
}
@@ -43,7 +58,7 @@ const StyledMarkdown = styled.div`
}
word-wrap: break-word;
border-radius: 5px;
background-color: ${BG_COLOR};
background-color: ${CODE_BLOCK_BG_COLOR};
font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
font-family: var(--vscode-editor-font-family);
}
@@ -53,7 +68,7 @@ const StyledMarkdown = styled.div`
color: #f78383;
}
background-color: ${BG_COLOR};
background-color: ${CODE_BLOCK_BG_COLOR};
font-family: var(--vscode-font-family), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
@@ -84,7 +99,7 @@ const StyledPre = styled.pre<{ theme: any }>`
.join("")}
`
const CodeBlock = memo(({ source }: { source?: string }) => {
const CodeBlock = memo(({ source, forceWrap = false }: CodeBlockProps) => {
const { theme } = useExtensionState()
const [reactContent, setMarkdownSource] = useRemark({
remarkPlugins: [
@@ -121,11 +136,11 @@ const CodeBlock = memo(({ source }: { source?: string }) => {
return (
<div
style={{
overflowY: "auto",
maxHeight: "100%",
backgroundColor: BG_COLOR,
overflowY: forceWrap ? "visible" : "auto",
maxHeight: forceWrap ? "none" : "100%",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<StyledMarkdown>{reactContent}</StyledMarkdown>
<StyledMarkdown forceWrap={forceWrap}>{reactContent}</StyledMarkdown>
</div>
)
})

View File

@@ -109,7 +109,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
justifyContent: "space-between",
alignItems: "center",
}}>
<span style={{ fontWeight: "bold", fontSize: "16px" }}>Task</span>
<span style={{ fontWeight: "bold" }}>Task</span>
<VSCodeButton
appearance="icon"
onClick={onClose}