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 { HistoryItem } from "./shared/HistoryItem"
import { Tool, ToolName } from "./shared/Tool" import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAskResponse } from "./shared/WebviewMessage" import { ClaudeAskResponse } from "./shared/WebviewMessage"
import { findLast, findLastIndex } from "./utils" import { findLast, findLastIndex, formatContentBlockToMarkdown } from "./utils"
import { truncateHalfConversation } from "./utils/context-management" import { truncateHalfConversation } from "./utils/context-management"
import { regexSearchFiles } from "./utils/ripgrep" import { regexSearchFiles } from "./utils/ripgrep"
import { extractTextFromFile } from "./utils/extract-text" import { extractTextFromFile } from "./utils/extract-text"
@@ -281,7 +281,7 @@ export class ClaudeDev {
) { ) {
this.providerRef = new WeakRef(provider) this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration) this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager(provider.context) this.terminalManager = new TerminalManager()
this.customInstructions = customInstructions this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -445,31 +445,6 @@ export class ClaudeDev {
await this.providerRef.deref()?.postStateToWebview() 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> { private async startTask(task?: string, images?: string[]): Promise<void> {
// conversationHistory (for API) and claudeMessages (for webview) need to be in sync // 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) // 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( await this.say(
"api_req_started", "api_req_started",
JSON.stringify({ JSON.stringify({
request: this.api.createUserReadableRequest([ request: `${taskText}\n\n<potentially_relevant_details>\nLoading...\n</potentially_relevant_details>`,
{
type: "text",
text: `${taskText}\n\n<potentially_relevant_details>(see getPotentiallyRelevantDetails in src/ClaudeDev.ts)</potentially_relevant_details>`,
},
...imageBlocks,
]),
}) })
) )
this.shouldSkipNextApiReqStartedMessage = true 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([ await this.initiateTaskLoop([
{ {
type: "text", 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, ...imageBlocks,
]) ])
@@ -687,8 +660,7 @@ export class ClaudeDev {
: "") + : "") +
(newUserContentText (newUserContentText
? `\n\nNew instructions for task continuation:\n<user_message>\n${newUserContentText}\n</user_message>\n` ? `\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 newUserContentImages = newUserContent.filter((block) => block.type === "image")
const combinedModifiedOldUserContentWithNewUserContent: UserContent = ( const combinedModifiedOldUserContentWithNewUserContent: UserContent = (
@@ -781,21 +753,12 @@ export class ClaudeDev {
async writeToFile(relPath?: string, newContent?: string): Promise<ToolResponse> { async writeToFile(relPath?: string, newContent?: string): Promise<ToolResponse> {
if (relPath === undefined) { if (relPath === undefined) {
await this.say(
"error",
"Claude tried to use write_to_file without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++ 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) { 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++ 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 this.consecutiveMistakeCount = 0
try { try {
@@ -991,9 +954,9 @@ export class ClaudeDev {
if (response === "messageResponse") { if (response === "messageResponse") {
await this.say("user_feedback", text, images) 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() const editedContent = updatedDocument.getText()
@@ -1070,9 +1033,11 @@ export class ClaudeDev {
diff: this.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent), diff: this.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent),
} as ClaudeSayTool) } 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 { } else {
return `The content was successfully saved to ${relPath}.` return this.formatToolResult(`The content was successfully saved to ${relPath}.`)
} }
} catch (error) { } catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}` const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
@@ -1080,7 +1045,7 @@ export class ClaudeDev {
"error", "error",
`Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` `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> { async readFile(relPath?: string): Promise<ToolResponse> {
if (relPath === undefined) { if (relPath === undefined) {
await this.say(
"error",
"Claude tried to use read_file without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
try { try {
@@ -1176,9 +1137,9 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") { if (response !== "yesButtonTapped") {
if (response === "messageResponse") { if (response === "messageResponse") {
await this.say("user_feedback", text, images) 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",
`Error reading file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` `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> { async listFiles(relDirPath?: string, recursiveRaw?: string): Promise<ToolResponse> {
if (relDirPath === undefined) { if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use list_files without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
try { try {
@@ -1221,13 +1178,13 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") { if (response !== "yesButtonTapped") {
if (response === "messageResponse") { if (response === "messageResponse") {
await this.say("user_feedback", text, images) 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) { } catch (error) {
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
await this.say( await this.say(
@@ -1236,7 +1193,7 @@ export class ClaudeDev {
error.message ?? JSON.stringify(serializeError(error), null, 2) 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> { async listCodeDefinitionNames(relDirPath?: string): Promise<ToolResponse> {
if (relDirPath === undefined) { if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use list_code_definition_names without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
try { try {
@@ -1325,13 +1278,13 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") { if (response !== "yesButtonTapped") {
if (response === "messageResponse") { if (response === "messageResponse") {
await this.say("user_feedback", text, images) 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) { } catch (error) {
const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}` const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}`
await this.say( await this.say(
@@ -1340,26 +1293,18 @@ export class ClaudeDev {
error.message ?? JSON.stringify(serializeError(error), null, 2) 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> { async searchFiles(relDirPath: string, regex: string, filePattern?: string): Promise<ToolResponse> {
if (relDirPath === undefined) { if (relDirPath === undefined) {
await this.say(
"error",
"Claude tried to use search_files without value for required parameter 'path'. Retrying..."
)
this.consecutiveMistakeCount++ 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) { if (regex === undefined) {
await this.say(
"error",
`Claude tried to use search_files without value for required parameter 'regex'. Retrying...`
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
try { try {
@@ -1381,40 +1326,36 @@ export class ClaudeDev {
if (response !== "yesButtonTapped") { if (response !== "yesButtonTapped") {
if (response === "messageResponse") { if (response === "messageResponse") {
await this.say("user_feedback", text, images) 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) { } catch (error) {
const errorString = `Error searching files: ${JSON.stringify(serializeError(error))}` const errorString = `Error searching files: ${JSON.stringify(serializeError(error))}`
await this.say( await this.say(
"error", "error",
`Error searching files:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` `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> { async executeCommand(command?: string, returnEmptyStringOnSuccess: boolean = false): Promise<ToolResponse> {
if (command === undefined) { if (command === undefined) {
await this.say(
"error",
"Claude tried to use execute_command without value for required parameter 'command'. Retrying..."
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
const { response, text, images } = await this.ask("command", command) const { response, text, images } = await this.ask("command", command)
if (response !== "yesButtonTapped") { if (response !== "yesButtonTapped") {
if (response === "messageResponse") { if (response === "messageResponse") {
await this.say("user_feedback", text, images) 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 { try {
@@ -1439,7 +1380,7 @@ export class ClaudeDev {
let result = "" let result = ""
process.on("line", (line) => { process.on("line", (line) => {
console.log("sending line from here", line) console.log("New line from process:", line)
result += line result += line
sendCommandOutput(line) sendCommandOutput(line)
}) })
@@ -1455,10 +1396,8 @@ export class ClaudeDev {
if (userFeedback) { if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images) await this.say("user_feedback", userFeedback.text, userFeedback.images)
return this.formatIntoToolResponse( return this.formatToolResponseWithImages(
`Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n<feedback>\n${ `Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.text
}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
userFeedback.images userFeedback.images
) )
} }
@@ -1467,39 +1406,31 @@ export class ClaudeDev {
if (returnEmptyStringOnSuccess) { if (returnEmptyStringOnSuccess) {
return "" return ""
} }
return `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}` return await this.formatToolResult(`Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`)
} catch (error) { } catch (error) {
let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2) let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2)
const errorString = `Error executing command:\n${errorMessage}` const errorString = `Error executing command:\n${errorMessage}`
await this.say("error", `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> { async askFollowupQuestion(question?: string): Promise<ToolResponse> {
if (question === undefined) { if (question === undefined) {
await this.say(
"error",
"Claude tried to use ask_followup_question without value for required parameter 'question'. Retrying..."
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
const { text, images } = await this.ask("followup", question) const { text, images } = await this.ask("followup", question)
await this.say("user_feedback", text ?? "", images) 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> { async attemptCompletion(result?: string, command?: string): Promise<ToolResponse> {
// result is required, command is optional // result is required, command is optional
if (result === undefined) { if (result === undefined) {
await this.say(
"error",
"Claude tried to use attempt_completion without value for required parameter 'result'. Retrying..."
)
this.consecutiveMistakeCount++ 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 this.consecutiveMistakeCount = 0
let resultToSend = result 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) 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) await this.say("user_feedback", text ?? "", images)
return this.formatIntoToolResponse( 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>\n\n${await this.getPotentiallyRelevantDetails()}`, `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 images
) )
} }
@@ -1600,7 +1531,7 @@ ${this.customInstructions.trim()}
...[ ...[
{ {
type: "text", 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, } as Anthropic.Messages.TextBlockParam,
...this.formatImagesIntoBlocks(images), ...this.formatImagesIntoBlocks(images),
] ]
@@ -1609,15 +1540,15 @@ ${this.customInstructions.trim()}
this.consecutiveMistakeCount = 0 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 }) await this.addToApiConversationHistory({ role: "user", content: userContent })
if (!this.shouldSkipNextApiReqStartedMessage) { if (!this.shouldSkipNextApiReqStartedMessage) {
await this.say( await this.say(
"api_req_started", "api_req_started",
// what the user sees in the webview JSON.stringify({ request: userContent.map(formatContentBlockToMarkdown).join("\n\n") })
JSON.stringify({
request: this.api.createUserReadableRequest(userContent),
})
) )
} else { } else {
this.shouldSkipNextApiReqStartedMessage = false 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> let details = `<potentially_relevant_details>
# VSCode Visible Files: # VSCode Visible Files:
${ ${
@@ -1769,25 +1741,76 @@ ${
.filter(Boolean) .filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath)) .map((absolutePath) => path.relative(cwd, absolutePath))
.join("\n") || "(No tabs open)" .join("\n") || "(No tabs open)"
} }`
`
if (verbose) { const busyTerminals = this.terminalManager.getBusyTerminals()
const isDesktop = cwd === path.join(os.homedir(), "Desktop") if (busyTerminals.length > 0) {
const files = await listFiles(cwd, !isDesktop) details += "\n\n# Active Terminals:"
const result = this.formatFilesList(cwd, files) for (const busyTerminal of busyTerminals) {
details += `\n# Current Working Directory ('${cwd}') File Structure:${ details += `\n## Original command:\n${busyTerminal.lastCommand}`
isDesktop const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id)
? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)" if (newOutput) {
: "" details += `\n## New output since last check:\n${newOutput}`
}:\n${result}\n` }
}
} }
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 return details
} }
async formatGenericToolFeedback(feedback?: string) { async formatToolDeniedFeedback(feedback?: string) {
return `The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}` 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 { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." import { ApiHandler, ApiHandlerMessageResponse } from "."
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../shared/api" import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../shared/api"
export class AnthropicHandler implements ApiHandler { 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 } { getModel(): { id: AnthropicModelId; info: ModelInfo } {
const modelId = this.options.apiModelId const modelId = this.options.apiModelId
if (modelId && modelId in anthropicModels) { if (modelId && modelId in anthropicModels) {

View File

@@ -1,6 +1,6 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk" import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/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" import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../shared/api"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock // https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
@@ -39,24 +39,6 @@ export class AwsBedrockHandler implements ApiHandler {
return { message } 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 } { getModel(): { id: BedrockModelId; info: ModelInfo } {
const modelId = this.options.apiModelId const modelId = this.options.apiModelId
if (modelId && modelId in bedrockModels) { if (modelId && modelId in bedrockModels) {

View File

@@ -19,15 +19,6 @@ export interface ApiHandler {
tools: Anthropic.Messages.Tool[] tools: Anthropic.Messages.Tool[]
): Promise<ApiHandlerMessageResponse> ): Promise<ApiHandlerMessageResponse>
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any
getModel(): { id: string; info: ModelInfo } getModel(): { id: string; info: ModelInfo }
} }
@@ -50,31 +41,3 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new AnthropicHandler(options) 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 { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai" import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api"
import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format" import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format"
@@ -48,23 +48,6 @@ export class OllamaHandler implements ApiHandler {
return { message: anthropicMessage } 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 } { getModel(): { id: string; info: ModelInfo } {
return { return {
id: this.options.ollamaModelId ?? "", id: this.options.ollamaModelId ?? "",

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk" import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai" import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." import { ApiHandler, ApiHandlerMessageResponse } from "."
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api"
import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format" import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format"
@@ -48,23 +48,6 @@ export class OpenAiHandler implements ApiHandler {
return { message: anthropicMessage } 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 } { getModel(): { id: string; info: ModelInfo } {
return { return {
id: this.options.openAiModelId ?? "", id: this.options.openAiModelId ?? "",

View File

@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk" import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai" import OpenAI from "openai"
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." import { ApiHandler, ApiHandlerMessageResponse } from "."
import { import {
ApiHandlerOptions, ApiHandlerOptions,
ModelInfo, ModelInfo,
@@ -177,24 +177,6 @@ export class OpenRouterHandler implements ApiHandler {
return completion 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 } { getModel(): { id: OpenRouterModelId; info: ModelInfo } {
const modelId = this.options.apiModelId const modelId = this.options.apiModelId
if (modelId && modelId in openRouterModels) { if (modelId && modelId in openRouterModels) {

View File

@@ -1,6 +1,6 @@
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { Anthropic } from "@anthropic-ai/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" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../shared/api"
// https://docs.anthropic.com/en/api/claude-on-vertex-ai // https://docs.anthropic.com/en/api/claude-on-vertex-ai
@@ -33,24 +33,6 @@ export class VertexHandler implements ApiHandler {
return { message } 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 } { getModel(): { id: VertexModelId; info: ModelInfo } {
const modelId = this.options.apiModelId const modelId = this.options.apiModelId
if (modelId && modelId in vertexModels) { if (modelId && modelId in vertexModels) {

View File

@@ -1,6 +1,6 @@
import * as vscode from "vscode" import * as vscode from "vscode"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import delay from "delay" import pWaitFor from "p-wait-for"
/* /*
TerminalManager: TerminalManager:
@@ -21,6 +21,14 @@ Enables flexible command execution:
- Continue execution in background - Continue execution in background
- Retrieve missed output later - 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: Example:
const terminalManager = new TerminalManager(context); const terminalManager = new TerminalManager(context);
@@ -41,77 +49,75 @@ process.continue();
// Later, if you need to get the unretrieved output: // Later, if you need to get the unretrieved output:
const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId); const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId);
console.log('Unretrieved output:', unretrievedOutput); 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 { export class TerminalManager {
private terminals: TerminalInfo[] = [] private terminals: TerminalInfo[] = []
private processes: Map<number, TerminalProcess> = new Map() private processes: Map<number, TerminalProcess> = new Map()
private context: vscode.ExtensionContext
private nextTerminalId = 1 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 { runCommand(terminalInfo: TerminalInfo, command: string, cwd: string): TerminalProcessResultPromise {
terminalInfo.busy = true terminalInfo.busy = true
terminalInfo.lastCommand = command terminalInfo.lastCommand = command
const process = new TerminalProcess()
const process = new TerminalProcess(terminalInfo, command)
this.processes.set(terminalInfo.id, process) 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) => { const promise = new Promise<void>((resolve, reject) => {
process.once(CONTINUE_EVENT, () => { process.once("continue", () => {
console.log("2") console.log(`continue received for terminal ${terminalInfo.id}`)
resolve() 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 shell integration is already active, run the command immediately
if (terminalInfo.terminal.shellIntegration) { if (terminalInfo.terminal.shellIntegration) {
console.log(`Shell integration active for terminal ${terminalInfo.id}, running command immediately`)
process.waitForShellIntegration = false process.waitForShellIntegration = false
process.run() process.run(terminalInfo.terminal, command)
}
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)
} else { } else {
// User doesn't have shell integration API available, run command the old way console.log(`Waiting for shell integration for terminal ${terminalInfo.id}`)
process.waitForShellIntegration = false // docs recommend waiting 3s for shell integration to activate
process.run() 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) return mergePromise(process, promise)
} }
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> { async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
const availableTerminal = this.terminals.find((t) => { 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 return false
} }
const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal 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 return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath
}) })
if (availableTerminal) { if (availableTerminal) {
console.log("reusing terminal", availableTerminal.id) console.log("Reusing terminal", availableTerminal.id)
return availableTerminal return availableTerminal
} }
@@ -140,63 +146,10 @@ export class TerminalManager {
return newTerminalInfo 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 }[] { getBusyTerminals(): { id: number; lastCommand: string }[] {
return this.terminals.filter((t) => t.busy).map((t) => ({ id: t.id, lastCommand: t.lastCommand })) 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 { getUnretrievedOutput(terminalId: number): string {
const process = this.processes.get(terminalId) const process = this.processes.get(terminalId)
if (!process) { if (!process) {
@@ -206,19 +159,14 @@ export class TerminalManager {
} }
disposeAll() { disposeAll() {
for (const info of this.terminals) { // for (const info of this.terminals) {
info.terminal.dispose() // todo do we want to do this? test with tab view closing it // //info.terminal.dispose() // dont want to dispose terminals when task is aborted
} // }
this.terminals = [] this.terminals = []
this.processes.clear() this.processes.clear()
} }
} }
function hasShellIntegrationApis(): boolean {
const [major, minor] = vscode.version.split(".").map(Number)
return major > 1 || (major === 1 && minor >= 93)
}
interface TerminalInfo { interface TerminalInfo {
terminal: vscode.Terminal terminal: vscode.Terminal
busy: boolean busy: boolean
@@ -226,54 +174,59 @@ interface TerminalInfo {
id: number 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 waitForShellIntegration: boolean = true
private isListening: boolean = true private isListening: boolean = true
private buffer: string = "" private buffer: string = ""
private execution?: vscode.TerminalShellExecution
private stream?: AsyncIterable<string>
private fullOutput: string = "" private fullOutput: string = ""
private lastRetrievedIndex: number = 0 private lastRetrievedIndex: number = 0
constructor(public terminalInfo: TerminalInfo, private command: string) { // constructor() {
super() // super()
}
async run() { async run(terminal: vscode.Terminal, command: string) {
if (this.terminalInfo.terminal.shellIntegration) { if (terminal.shellIntegration) {
this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command) console.log(`Shell integration available for terminal`)
this.stream = this.execution.read() const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors // todo: need to handle errors
let isFirstChunk = true // ignore first chunk since it's vscode shell integration marker for await (const data of stream) {
for await (const data of this.stream) { console.log(`Received data chunk for terminal:`, data)
console.log("data", data)
if (!isFirstChunk) {
this.fullOutput += data this.fullOutput += data
if (this.isListening) { if (this.isListening) {
console.log(`Emitting data for terminal`)
this.emitIfEol(data) this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
} }
} else {
isFirstChunk = false
}
} }
// Emit any remaining content in the buffer // Emit any remaining content in the buffer
if (this.buffer && this.isListening) { if (this.buffer && this.isListening) {
console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
this.emit("line", this.buffer.trim()) this.emit("line", this.buffer.trim())
this.buffer = "" this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length this.lastRetrievedIndex = this.fullOutput.length
} }
this.emit(CONTINUE_EVENT) console.log(`Command execution completed for terminal`)
this.emit("continue")
this.emit("completed")
} else { } 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 // For terminals without shell integration, we can't know when the command completes
// So we'll just emit the continue event after a delay // So we'll just emit the continue event after a delay
setTimeout(() => { 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 }, 2000) // Adjust this delay as needed
} }
} }
@@ -294,13 +247,17 @@ export class TerminalProcess extends EventEmitter {
} }
continue() { continue() {
this.isListening = false // Emit any remaining content in the buffer
this.removeAllListeners("line") if (this.buffer && this.isListening) {
this.emit(CONTINUE_EVENT) console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
this.emit("line", this.buffer.trim())
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
} }
isStillListening() { this.isListening = false
return this.isListening this.removeAllListeners("line")
this.emit("continue")
} }
getUnretrievedOutput(): string { getUnretrievedOutput(): string {

View File

@@ -4,7 +4,7 @@ import os from "os"
import * as path from "path" import * as path from "path"
import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser" 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. // TODO: implement caching behavior to avoid having to keep analyzing project for new tasks.
export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise<string> { 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: block:
| Anthropic.TextBlockParam | Anthropic.TextBlockParam
| Anthropic.ImageBlockParam | Anthropic.ImageBlockParam

View File

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

View File

@@ -5,7 +5,7 @@ import styled from "styled-components"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import { useExtensionState } from "../context/ExtensionStateContext" 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. 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", 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 { pre {
background-color: ${BG_COLOR}; background-color: ${CODE_BLOCK_BG_COLOR};
border-radius: 5px; border-radius: 5px;
margin: 0; margin: 0;
min-width: max-content; min-width: ${({ forceWrap }) => (forceWrap ? "auto" : "max-content")};
padding: 10px 10px; padding: 10px 10px;
} }
@@ -43,7 +58,7 @@ const StyledMarkdown = styled.div`
} }
word-wrap: break-word; word-wrap: break-word;
border-radius: 5px; 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-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
font-family: var(--vscode-editor-font-family); font-family: var(--vscode-editor-font-family);
} }
@@ -53,7 +68,7 @@ const StyledMarkdown = styled.div`
color: #f78383; 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, font-family: var(--vscode-font-family), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
@@ -84,7 +99,7 @@ const StyledPre = styled.pre<{ theme: any }>`
.join("")} .join("")}
` `
const CodeBlock = memo(({ source }: { source?: string }) => { const CodeBlock = memo(({ source, forceWrap = false }: CodeBlockProps) => {
const { theme } = useExtensionState() const { theme } = useExtensionState()
const [reactContent, setMarkdownSource] = useRemark({ const [reactContent, setMarkdownSource] = useRemark({
remarkPlugins: [ remarkPlugins: [
@@ -121,11 +136,11 @@ const CodeBlock = memo(({ source }: { source?: string }) => {
return ( return (
<div <div
style={{ style={{
overflowY: "auto", overflowY: forceWrap ? "visible" : "auto",
maxHeight: "100%", maxHeight: forceWrap ? "none" : "100%",
backgroundColor: BG_COLOR, backgroundColor: CODE_BLOCK_BG_COLOR,
}}> }}>
<StyledMarkdown>{reactContent}</StyledMarkdown> <StyledMarkdown forceWrap={forceWrap}>{reactContent}</StyledMarkdown>
</div> </div>
) )
}) })

View File

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