Add diagnostics context to environment details

This commit is contained in:
Saoud Rizwan
2024-09-14 13:45:41 -04:00
parent a6ff000ac8
commit d29f4a174c
6 changed files with 205 additions and 70 deletions

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "claude-dev", "name": "claude-dev",
"version": "1.6.9", "version": "1.6.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-dev", "name": "claude-dev",
"version": "1.6.9", "version": "1.6.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/bedrock-sdk": "^0.10.2",
@@ -21,6 +21,7 @@
"default-shell": "^2.2.0", "default-shell": "^2.2.0",
"delay": "^6.0.0", "delay": "^6.0.0",
"diff": "^5.2.0", "diff": "^5.2.0",
"fast-deep-equal": "^3.1.3",
"globby": "^14.0.2", "globby": "^14.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7", "monaco-vscode-textmate-theme-converter": "^0.1.7",
@@ -6143,7 +6144,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {

View File

@@ -157,6 +157,7 @@
"default-shell": "^2.2.0", "default-shell": "^2.2.0",
"delay": "^6.0.0", "delay": "^6.0.0",
"diff": "^5.2.0", "diff": "^5.2.0",
"fast-deep-equal": "^3.1.3",
"globby": "^14.0.2", "globby": "^14.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7", "monaco-vscode-textmate-theme-converter": "^0.1.7",

View File

@@ -26,6 +26,7 @@ import { findLast, findLastIndex, formatContentBlockToMarkdown } from "./utils"
import { truncateHalfConversation } from "./utils/context-management" import { truncateHalfConversation } from "./utils/context-management"
import { extractTextFromFile } from "./utils/extract-text" import { extractTextFromFile } from "./utils/extract-text"
import { regexSearchFiles } from "./utils/ripgrep" import { regexSearchFiles } from "./utils/ripgrep"
import DiagnosticsMonitor from "./integrations/DiagnosticsMonitor"
const SYSTEM_PROMPT = const SYSTEM_PROMPT =
async () => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. async () => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
@@ -249,6 +250,8 @@ export class ClaudeDev {
readonly taskId: string readonly taskId: string
private api: ApiHandler private api: ApiHandler
private terminalManager: TerminalManager private terminalManager: TerminalManager
private diagnosticsMonitor: DiagnosticsMonitor
private didEditFile: boolean = false
private customInstructions?: string private customInstructions?: string
private alwaysAllowReadOnly: boolean private alwaysAllowReadOnly: boolean
apiConversationHistory: Anthropic.MessageParam[] = [] apiConversationHistory: Anthropic.MessageParam[] = []
@@ -273,6 +276,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() this.terminalManager = new TerminalManager()
this.diagnosticsMonitor = new DiagnosticsMonitor()
this.customInstructions = customInstructions this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -673,6 +677,7 @@ export class ClaudeDev {
abortTask() { abortTask() {
this.abort = true // will stop any autonomously running promises this.abort = true // will stop any autonomously running promises
this.terminalManager.disposeAll() this.terminalManager.disposeAll()
this.diagnosticsMonitor.dispose()
} }
async executeTool(toolName: ToolName, toolInput: any): Promise<[boolean, ToolResponse]> { async executeTool(toolName: ToolName, toolInput: any): Promise<[boolean, ToolResponse]> {
@@ -971,10 +976,12 @@ export class ClaudeDev {
return [true, await this.formatToolDenied()] return [true, await this.formatToolDenied()]
} }
// Save the changes
const editedContent = updatedDocument.getText() const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) { if (updatedDocument.isDirty) {
await updatedDocument.save() await updatedDocument.save()
} }
this.didEditFile = true
// Read the potentially edited content from the document // Read the potentially edited content from the document
@@ -1620,8 +1627,9 @@ ${this.customInstructions.trim()}
"api_req_started", "api_req_started",
JSON.stringify({ JSON.stringify({
request: request:
userContent.map(formatContentBlockToMarkdown).join("\n\n") + userContent
"\n\n<environment_details>\nLoading...\n</environment_details>", .map((block) => formatContentBlockToMarkdown(block, this.apiConversationHistory))
.join("\n\n") + "\n\n<environment_details>\nLoading...\n</environment_details>",
}) })
) )
@@ -1636,7 +1644,9 @@ ${this.customInstructions.trim()}
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message // since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.claudeMessages, (m) => m.say === "api_req_started") const lastApiReqIndex = findLastIndex(this.claudeMessages, (m) => m.say === "api_req_started")
this.claudeMessages[lastApiReqIndex].text = JSON.stringify({ this.claudeMessages[lastApiReqIndex].text = JSON.stringify({
request: userContent.map(formatContentBlockToMarkdown).join("\n\n"), request: userContent
.map((block) => formatContentBlockToMarkdown(block, this.apiConversationHistory))
.join("\n\n"),
}) })
await this.saveClaudeMessages() await this.saveClaudeMessages()
await this.providerRef.deref()?.postStateToWebview() await this.providerRef.deref()?.postStateToWebview()
@@ -1816,74 +1826,52 @@ ${this.customInstructions.trim()}
} }
async getEnvironmentDetails(includeFileDetails: boolean = false) { async getEnvironmentDetails(includeFileDetails: boolean = false) {
let details = `<environment_details> let details = ""
# VSCode Visible Files
${
vscode.window.visibleTextEditors
?.map((editor) => editor.document?.uri?.fsPath)
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath))
.join("\n") || "(No files open)"
}
# VSCode Open Tabs const visibleFiles = vscode.window.visibleTextEditors
${ ?.map((editor) => editor.document?.uri?.fsPath)
vscode.window.tabGroups.all .filter(Boolean)
.flatMap((group) => group.tabs) .map((absolutePath) => path.relative(cwd, absolutePath))
.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) .join("\n")
.filter(Boolean) if (visibleFiles) {
.map((absolutePath) => path.relative(cwd, absolutePath)) details += `\n\n# VSCode Visible Files\n${visibleFiles}`
.join("\n") || "(No tabs open)" }
}`
// Get diagnostics for all open files in the workspace const openTabs = vscode.window.tabGroups.all
// const diagnostics = vscode.languages.getDiagnostics() .flatMap((group) => group.tabs)
// const relevantDiagnostics = diagnostics.filter(([_, fileDiagnostics]) => .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
// fileDiagnostics.some( .filter(Boolean)
// (d) => .map((absolutePath) => path.relative(cwd, absolutePath))
// d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning .join("\n")
// ) if (openTabs) {
// ) details += `\n\n# VSCode Open Tabs\n${openTabs}`
}
// if (relevantDiagnostics.length > 0) {
// details += "\n\n# VSCode 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}`
// }
// }
// }
// }
const busyTerminals = this.terminalManager.getTerminals(true) const busyTerminals = this.terminalManager.getTerminals(true)
if (busyTerminals.length > 0 || this.didEditFile) {
await delay(500) // delay after saving file to let terminals/diagnostics catch up
}
let terminalDetails = "" // want to place these at the end, but want to wait for diagnostics to load last since dev servers (compilers like webpack) will first re-compile and then send diagnostics
if (busyTerminals.length > 0) { if (busyTerminals.length > 0) {
// wait for terminals to cool down // wait for terminals to cool down
await delay(500) // delay after saving file
await pWaitFor(() => busyTerminals.every((t) => !this.terminalManager.isProcessHot(t.id)), { await pWaitFor(() => busyTerminals.every((t) => !this.terminalManager.isProcessHot(t.id)), {
interval: 100, interval: 100,
timeout: 15_000, timeout: 15_000,
}).catch(() => {}) }).catch(() => {})
// terminals are cool, let's retrieve their output // terminals are cool, let's retrieve their output
details += "\n\n# Active Terminals" terminalDetails += "\n\n# Active Terminals"
for (const busyTerminal of busyTerminals) { for (const busyTerminal of busyTerminals) {
details += `\n## ${busyTerminal.lastCommand}` terminalDetails += `\n## ${busyTerminal.lastCommand}`
const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id) const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id)
if (newOutput) { if (newOutput) {
details += `\n### New Output\n${newOutput}` terminalDetails += `\n### New Output\n${newOutput}`
} else { } else {
// details += `\n(Still running, no new output)` // don't want to show this right after running the command // details += `\n(Still running, no new output)` // don't want to show this right after running the command
} }
} }
} }
// only show inactive terminals if there's output to show // only show inactive terminals if there's output to show
const inactiveTerminals = this.terminalManager.getTerminals(false) const inactiveTerminals = this.terminalManager.getTerminals(false)
if (inactiveTerminals.length > 0) { if (inactiveTerminals.length > 0) {
@@ -1895,30 +1883,59 @@ ${
} }
} }
if (inactiveTerminalOutputs.size > 0) { if (inactiveTerminalOutputs.size > 0) {
details += "\n\n# Inactive Terminals" terminalDetails += "\n\n# Inactive Terminals"
for (const [terminalId, newOutput] of inactiveTerminalOutputs) { for (const [terminalId, newOutput] of inactiveTerminalOutputs) {
const inactiveTerminal = inactiveTerminals.find((t) => t.id === terminalId) const inactiveTerminal = inactiveTerminals.find((t) => t.id === terminalId)
if (inactiveTerminal) { if (inactiveTerminal) {
details += `\n## ${inactiveTerminal.lastCommand}` terminalDetails += `\n## ${inactiveTerminal.lastCommand}`
details += `\n### New Output\n${newOutput}` terminalDetails += `\n### New Output\n${newOutput}`
} }
} }
} }
} }
// we want to get diagnostics AFTER terminal for a few reasons: terminal could be scaffolding a project, compiler could send issues to diagnostics, etc.
let diagnosticsDetails = ""
const diagnostics = await this.diagnosticsMonitor.getCurrentDiagnostics(this.didEditFile) // if claude edited the workspace then wait for updated diagnostics
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter(
(d) =>
d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning
)
if (problems.length > 0) {
diagnosticsDetails += `\n## ${path.relative(cwd, uri.fsPath)}:`
for (const diagnostic of problems) {
let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning"
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
diagnosticsDetails += `\n- [${severity}] Line ${line}: ${diagnostic.message}`
}
}
}
this.didEditFile = false // reset, this lets us know when to wait for updated diagnostics
details += "\n\n# VSCode Workspace Diagnostics"
if (diagnosticsDetails) {
details += diagnosticsDetails
} else {
details += "\n(No problems detected)"
}
if (terminalDetails) {
details += terminalDetails
}
if (includeFileDetails) { if (includeFileDetails) {
const isDesktop = cwd === path.join(os.homedir(), "Desktop") const isDesktop = cwd === path.join(os.homedir(), "Desktop")
const files = await listFiles(cwd, !isDesktop) const files = await listFiles(cwd, !isDesktop)
const result = this.formatFilesList(cwd, files) const result = this.formatFilesList(cwd, files)
details += `\n\n# Current Working Directory ('${cwd}') Files${ details += `\n\n# Current Working Directory (${cwd}) Files\n${result}${
isDesktop isDesktop
? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)" ? "\n(Note: Only top-level contents shown for Desktop by default. Use list_files to explore further if necessary.)"
: "" : ""
}\n${result}` }`
} }
details += "\n</environment_details>" return `<environment_details>\n${details.trim()}\n</environment_details>`
return details
} }
async formatToolDeniedFeedback(feedback?: string) { async formatToolDeniedFeedback(feedback?: string) {

View File

@@ -0,0 +1,94 @@
import * as vscode from "vscode"
import deepEqual from "fast-deep-equal"
type FileDiagnostics = [vscode.Uri, vscode.Diagnostic[]][]
/*
About Diagnostics:
The Problems tab shows diagnostics that have been reported for your project. These diagnostics are categorized into:
Errors: Critical issues that usually prevent your code from compiling or running correctly.
Warnings: Potential problems in the code that may not prevent it from running but could cause issues (e.g., bad practices, unused variables).
Information: Non-critical suggestions or tips (e.g., formatting issues or notes from linters).
The Problems tab displays diagnostics from various sources:
1. Language Servers:
- TypeScript: Type errors, missing imports, syntax issues
- Python: Syntax errors, invalid type hints, undefined variables
- JavaScript/Node.js: Parsing and execution errors
2. Linters:
- ESLint: Code style, best practices, potential bugs
- Pylint: Unused imports, naming conventions
- TSLint: Style and correctness issues in TypeScript
3. Build Tools:
- Webpack: Module resolution failures, build errors
- Gulp: Build errors during task execution
4. Custom Validators:
- Extensions can generate custom diagnostics for specific languages or tools
Each problem typically indicates its source (e.g., language server, linter, build tool).
Diagnostics update in real-time as you edit code, helping identify issues quickly. For example, if you introduce a syntax error in a TypeScript file, the Problems tab will immediately display the new error.
Notes on diagnostics:
- linter diagnostics are only captured for open editors
- this works great for us since when claude edits/creates files its through vscode's textedit api's and we get those diagnostics for free
- some tools might require you to save the file or manually refresh to clear the problem from the list.
*/
class DiagnosticsMonitor {
private diagnosticsChangeEmitter: vscode.EventEmitter<void> = new vscode.EventEmitter<void>()
private disposables: vscode.Disposable[] = []
private lastDiagnostics: FileDiagnostics = []
constructor() {
this.disposables.push(
vscode.languages.onDidChangeDiagnostics(() => {
this.diagnosticsChangeEmitter.fire()
})
)
}
public async getCurrentDiagnostics(shouldWaitForChanges: boolean): Promise<FileDiagnostics> {
const currentDiagnostics = vscode.languages.getDiagnostics() // get all diagnostics for files open in workspace (not just errors/warnings so our did update check is more likely to detect updated diagnostics)
if (!shouldWaitForChanges) {
return currentDiagnostics
}
// it doesn't matter if we don't even have all the diagnostics yet, since claude will get the rest in the next request. as long as somethings changed, he can react to that in this request.
if (!deepEqual(this.lastDiagnostics, currentDiagnostics)) {
this.lastDiagnostics = currentDiagnostics
return currentDiagnostics
}
return this.waitForUpdatedDiagnostics()
}
private async waitForUpdatedDiagnostics(timeout: number = 1_000): Promise<FileDiagnostics> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup()
const finalDiagnostics = vscode.languages.getDiagnostics()
this.lastDiagnostics = finalDiagnostics
resolve(finalDiagnostics)
}, timeout)
const disposable = this.diagnosticsChangeEmitter.event(() => {
cleanup()
const updatedDiagnostics = vscode.languages.getDiagnostics()
this.lastDiagnostics = updatedDiagnostics
resolve(updatedDiagnostics)
})
const cleanup = () => {
clearTimeout(timer)
disposable.dispose()
}
})
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
this.diagnosticsChangeEmitter.dispose()
}
}
export default DiagnosticsMonitor

View File

@@ -22,7 +22,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
.map((message) => { .map((message) => {
const role = message.role === "user" ? "**User:**" : "**Assistant:**" const role = message.role === "user" ? "**User:**" : "**Assistant:**"
const content = Array.isArray(message.content) const content = Array.isArray(message.content)
? message.content.map(formatContentBlockToMarkdown).join("\n") ? message.content.map((block) => formatContentBlockToMarkdown(block, conversationHistory)).join("\n")
: message.content : message.content
return `${role}\n\n${content}\n\n` return `${role}\n\n${content}\n\n`
}) })
@@ -46,7 +46,8 @@ export function formatContentBlockToMarkdown(
| Anthropic.TextBlockParam | Anthropic.TextBlockParam
| Anthropic.ImageBlockParam | Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam | Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam | Anthropic.ToolResultBlockParam,
messages: Anthropic.MessageParam[]
): string { ): string {
switch (block.type) { switch (block.type) {
case "text": case "text":
@@ -64,16 +65,30 @@ export function formatContentBlockToMarkdown(
} }
return `[Tool Use: ${block.name}]\n${input}` return `[Tool Use: ${block.name}]\n${input}`
case "tool_result": case "tool_result":
const toolName = findToolName(block.tool_use_id, messages)
if (typeof block.content === "string") { if (typeof block.content === "string") {
return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content}` return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}`
} else if (Array.isArray(block.content)) { } else if (Array.isArray(block.content)) {
return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
.map(formatContentBlockToMarkdown) .map((contentBlock) => formatContentBlockToMarkdown(contentBlock, messages))
.join("\n")}` .join("\n")}`
} else { } else {
return `[Tool Result${block.is_error ? " (Error)" : ""}]` return `[${toolName}${block.is_error ? " (Error)" : ""}]`
} }
default: default:
return "[Unexpected content type]" return "[Unexpected content type]"
} }
} }
function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string {
for (const message of messages) {
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "tool_use" && block.id === toolCallId) {
return block.name
}
}
}
}
return "Unknown Tool"
}

View File

@@ -51,6 +51,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const virtuosoRef = useRef<VirtuosoHandle>(null) const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({}) const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const [isAtBottom, setIsAtBottom] = useState(false) const [isAtBottom, setIsAtBottom] = useState(false)
const [didScrollFromApiReq, setDidScrollFromApiReq] = useState(false)
useEffect(() => { useEffect(() => {
// if last message is an ask, show user ask UI // if last message is an ask, show user ask UI
@@ -461,15 +462,22 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
) )
useEffect(() => { useEffect(() => {
// dont scroll if we're just updating the api req started informational body
const isLastApiReqStarted = visibleMessages.at(-1)?.say === "api_req_started"
if (didScrollFromApiReq && isLastApiReqStarted) {
return
}
// We use a setTimeout to ensure new content is rendered before scrolling to the bottom. virtuoso's followOutput would scroll to the bottom before the new content could render. // We use a setTimeout to ensure new content is rendered before scrolling to the bottom. virtuoso's followOutput would scroll to the bottom before the new content could render.
const timer = setTimeout(() => { const timer = setTimeout(() => {
// TODO: we can use virtuoso's isAtBottom to prevent scrolling if user is scrolled up, and show a 'scroll to bottom' button for better UX // TODO: we can use virtuoso's isAtBottom to prevent scrolling if user is scrolled up, and show a 'scroll to bottom' button for better UX
// NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting // NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }) virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
setDidScrollFromApiReq(isLastApiReqStarted) // need to do this in timer since this effect can get called a few times simultaneously
}, 50) }, 50)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [visibleMessages]) }, [visibleMessages, didScrollFromApiReq])
const placeholderText = useMemo(() => { const placeholderText = useMemo(() => {
const text = task ? "Type a message..." : "Type your task here..." const text = task ? "Type a message..." : "Type your task here..."