From d29f4a174cf5073b12703ea084f17e3f95573def Mon Sep 17 00:00:00 2001
From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Date: Sat, 14 Sep 2024 13:45:41 -0400
Subject: [PATCH] Add diagnostics context to environment details
---
package-lock.json | 6 +-
package.json | 1 +
src/ClaudeDev.ts | 137 ++++++++++++++-----------
src/integrations/DiagnosticsMonitor.ts | 94 +++++++++++++++++
src/utils/export-markdown.ts | 27 +++--
webview-ui/src/components/ChatView.tsx | 10 +-
6 files changed, 205 insertions(+), 70 deletions(-)
create mode 100644 src/integrations/DiagnosticsMonitor.ts
diff --git a/package-lock.json b/package-lock.json
index 1322beb..7b913c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
- "version": "1.6.9",
+ "version": "1.6.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
- "version": "1.6.9",
+ "version": "1.6.10",
"license": "MIT",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
@@ -21,6 +21,7 @@
"default-shell": "^2.2.0",
"delay": "^6.0.0",
"diff": "^5.2.0",
+ "fast-deep-equal": "^3.1.3",
"globby": "^14.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
@@ -6143,7 +6144,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
diff --git a/package.json b/package.json
index abb1bc7..cd45d14 100644
--- a/package.json
+++ b/package.json
@@ -157,6 +157,7 @@
"default-shell": "^2.2.0",
"delay": "^6.0.0",
"diff": "^5.2.0",
+ "fast-deep-equal": "^3.1.3",
"globby": "^14.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
diff --git a/src/ClaudeDev.ts b/src/ClaudeDev.ts
index 077a852..7282807 100644
--- a/src/ClaudeDev.ts
+++ b/src/ClaudeDev.ts
@@ -26,6 +26,7 @@ import { findLast, findLastIndex, formatContentBlockToMarkdown } from "./utils"
import { truncateHalfConversation } from "./utils/context-management"
import { extractTextFromFile } from "./utils/extract-text"
import { regexSearchFiles } from "./utils/ripgrep"
+import DiagnosticsMonitor from "./integrations/DiagnosticsMonitor"
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.
@@ -249,6 +250,8 @@ export class ClaudeDev {
readonly taskId: string
private api: ApiHandler
private terminalManager: TerminalManager
+ private diagnosticsMonitor: DiagnosticsMonitor
+ private didEditFile: boolean = false
private customInstructions?: string
private alwaysAllowReadOnly: boolean
apiConversationHistory: Anthropic.MessageParam[] = []
@@ -273,6 +276,7 @@ export class ClaudeDev {
this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager()
+ this.diagnosticsMonitor = new DiagnosticsMonitor()
this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -673,6 +677,7 @@ export class ClaudeDev {
abortTask() {
this.abort = true // will stop any autonomously running promises
this.terminalManager.disposeAll()
+ this.diagnosticsMonitor.dispose()
}
async executeTool(toolName: ToolName, toolInput: any): Promise<[boolean, ToolResponse]> {
@@ -971,10 +976,12 @@ export class ClaudeDev {
return [true, await this.formatToolDenied()]
}
+ // Save the changes
const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
+ this.didEditFile = true
// Read the potentially edited content from the document
@@ -1620,8 +1627,9 @@ ${this.customInstructions.trim()}
"api_req_started",
JSON.stringify({
request:
- userContent.map(formatContentBlockToMarkdown).join("\n\n") +
- "\n\n\nLoading...\n",
+ userContent
+ .map((block) => formatContentBlockToMarkdown(block, this.apiConversationHistory))
+ .join("\n\n") + "\n\n\nLoading...\n",
})
)
@@ -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
const lastApiReqIndex = findLastIndex(this.claudeMessages, (m) => m.say === "api_req_started")
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.providerRef.deref()?.postStateToWebview()
@@ -1816,74 +1826,52 @@ ${this.customInstructions.trim()}
}
async getEnvironmentDetails(includeFileDetails: boolean = false) {
- 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)"
-}
+ let details = ""
-# VSCode Open Tabs
-${
- vscode.window.tabGroups.all
- .flatMap((group) => group.tabs)
- .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
- .filter(Boolean)
- .map((absolutePath) => path.relative(cwd, absolutePath))
- .join("\n") || "(No tabs open)"
-}`
+ const visibleFiles = vscode.window.visibleTextEditors
+ ?.map((editor) => editor.document?.uri?.fsPath)
+ .filter(Boolean)
+ .map((absolutePath) => path.relative(cwd, absolutePath))
+ .join("\n")
+ if (visibleFiles) {
+ details += `\n\n# VSCode Visible Files\n${visibleFiles}`
+ }
- // 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# 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 openTabs = vscode.window.tabGroups.all
+ .flatMap((group) => group.tabs)
+ .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
+ .filter(Boolean)
+ .map((absolutePath) => path.relative(cwd, absolutePath))
+ .join("\n")
+ if (openTabs) {
+ details += `\n\n# VSCode Open Tabs\n${openTabs}`
+ }
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) {
// wait for terminals to cool down
- await delay(500) // delay after saving file
await pWaitFor(() => busyTerminals.every((t) => !this.terminalManager.isProcessHot(t.id)), {
interval: 100,
timeout: 15_000,
}).catch(() => {})
// terminals are cool, let's retrieve their output
- details += "\n\n# Active Terminals"
+ terminalDetails += "\n\n# Active Terminals"
for (const busyTerminal of busyTerminals) {
- details += `\n## ${busyTerminal.lastCommand}`
+ terminalDetails += `\n## ${busyTerminal.lastCommand}`
const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id)
if (newOutput) {
- details += `\n### New Output\n${newOutput}`
+ terminalDetails += `\n### New Output\n${newOutput}`
} else {
// 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
const inactiveTerminals = this.terminalManager.getTerminals(false)
if (inactiveTerminals.length > 0) {
@@ -1895,30 +1883,59 @@ ${
}
}
if (inactiveTerminalOutputs.size > 0) {
- details += "\n\n# Inactive Terminals"
+ terminalDetails += "\n\n# Inactive Terminals"
for (const [terminalId, newOutput] of inactiveTerminalOutputs) {
const inactiveTerminal = inactiveTerminals.find((t) => t.id === terminalId)
if (inactiveTerminal) {
- details += `\n## ${inactiveTerminal.lastCommand}`
- details += `\n### New Output\n${newOutput}`
+ terminalDetails += `\n## ${inactiveTerminal.lastCommand}`
+ 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) {
const isDesktop = cwd === path.join(os.homedir(), "Desktop")
const files = await listFiles(cwd, !isDesktop)
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
- ? "\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"
- return details
+ return `\n${details.trim()}\n`
}
async formatToolDeniedFeedback(feedback?: string) {
diff --git a/src/integrations/DiagnosticsMonitor.ts b/src/integrations/DiagnosticsMonitor.ts
new file mode 100644
index 0000000..da109a7
--- /dev/null
+++ b/src/integrations/DiagnosticsMonitor.ts
@@ -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 = new vscode.EventEmitter()
+ private disposables: vscode.Disposable[] = []
+ private lastDiagnostics: FileDiagnostics = []
+
+ constructor() {
+ this.disposables.push(
+ vscode.languages.onDidChangeDiagnostics(() => {
+ this.diagnosticsChangeEmitter.fire()
+ })
+ )
+ }
+
+ public async getCurrentDiagnostics(shouldWaitForChanges: boolean): Promise {
+ 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 {
+ 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
diff --git a/src/utils/export-markdown.ts b/src/utils/export-markdown.ts
index fe0502f..f8479dd 100644
--- a/src/utils/export-markdown.ts
+++ b/src/utils/export-markdown.ts
@@ -22,7 +22,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
.map((message) => {
const role = message.role === "user" ? "**User:**" : "**Assistant:**"
const content = Array.isArray(message.content)
- ? message.content.map(formatContentBlockToMarkdown).join("\n")
+ ? message.content.map((block) => formatContentBlockToMarkdown(block, conversationHistory)).join("\n")
: message.content
return `${role}\n\n${content}\n\n`
})
@@ -46,7 +46,8 @@ export function formatContentBlockToMarkdown(
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
- | Anthropic.ToolResultBlockParam
+ | Anthropic.ToolResultBlockParam,
+ messages: Anthropic.MessageParam[]
): string {
switch (block.type) {
case "text":
@@ -64,16 +65,30 @@ export function formatContentBlockToMarkdown(
}
return `[Tool Use: ${block.name}]\n${input}`
case "tool_result":
+ const toolName = findToolName(block.tool_use_id, messages)
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)) {
- return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content
- .map(formatContentBlockToMarkdown)
+ return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
+ .map((contentBlock) => formatContentBlockToMarkdown(contentBlock, messages))
.join("\n")}`
} else {
- return `[Tool Result${block.is_error ? " (Error)" : ""}]`
+ return `[${toolName}${block.is_error ? " (Error)" : ""}]`
}
default:
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"
+}
diff --git a/webview-ui/src/components/ChatView.tsx b/webview-ui/src/components/ChatView.tsx
index 24074d2..6e2a362 100644
--- a/webview-ui/src/components/ChatView.tsx
+++ b/webview-ui/src/components/ChatView.tsx
@@ -51,6 +51,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const virtuosoRef = useRef(null)
const [expandedRows, setExpandedRows] = useState>({})
const [isAtBottom, setIsAtBottom] = useState(false)
+ const [didScrollFromApiReq, setDidScrollFromApiReq] = useState(false)
useEffect(() => {
// if last message is an ask, show user ask UI
@@ -461,15 +462,22 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)
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.
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
// 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" })
+ setDidScrollFromApiReq(isLastApiReqStarted) // need to do this in timer since this effect can get called a few times simultaneously
}, 50)
return () => clearTimeout(timer)
- }, [visibleMessages])
+ }, [visibleMessages, didScrollFromApiReq])
const placeholderText = useMemo(() => {
const text = task ? "Type a message..." : "Type your task here..."