From d63aef015a110d48cd17e51c3fe2c3bc43253ea6 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:05:29 -0400 Subject: [PATCH] Add tool message types; show tool specific information in webview; separate command from output; add abort button to task card --- src/ClaudeDev.ts | 72 +++++++------ src/shared/ExtensionMessage.ts | 9 +- src/shared/WebviewMessage.ts | 2 +- webview-ui/src/components/ChatRow.tsx | 100 ++++++++++++++---- webview-ui/src/components/ChatView.tsx | 22 +++- webview-ui/src/components/TaskHeader.tsx | 53 ++++++---- .../src/utilities/combineCommandSequences.ts | 7 ++ 7 files changed, 191 insertions(+), 74 deletions(-) diff --git a/src/ClaudeDev.ts b/src/ClaudeDev.ts index 9c816f9..ca3b2cf 100644 --- a/src/ClaudeDev.ts +++ b/src/ClaudeDev.ts @@ -9,7 +9,7 @@ import * as path from "path" import { serializeError } from "serialize-error" import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants" import { Tool, ToolName } from "./shared/Tool" -import { ClaudeAsk, ClaudeSay, ExtensionMessage } from "./shared/ExtensionMessage" +import { ClaudeAsk, ClaudeSay, ClaudeSayTool, ExtensionMessage } from "./shared/ExtensionMessage" import * as vscode from "vscode" import pWaitFor from "p-wait-for" import { ClaudeAskResponse } from "./shared/WebviewMessage" @@ -214,7 +214,7 @@ export class ClaudeDev { await this.providerRef.deref()?.postStateToWebview() // Get all relevant context for the task - const filesInCurrentDir = await this.listFiles(".") + const filesInCurrentDir = await this.listFiles(".", false) // This first message kicks off a task, it is not included in every subsequent message. This is a good place to give all the relevant context to a task, instead of having Claude request for it using tools. let userPrompt = `# Task @@ -235,7 +235,7 @@ ${filesInCurrentDir}` ${activeEditorContents}` } - await this.say("text", userPrompt) + await this.say("text", task) let totalInputTokens = 0 let totalOutputTokens = 0 @@ -255,10 +255,10 @@ ${activeEditorContents}` //this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`) break } else { - this.say( - "tool", - "Claude responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..." - ) + // this.say( + // "tool", + // "Claude responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..." + // ) userPrompt = "Ask yourself if you have completed the user's task. If you have, use the attempt_completion tool, otherwise proceed to the next step. (This is an automated message, so do not respond to it conversationally. Just proceed with the task.)" } @@ -305,38 +305,46 @@ ${activeEditorContents}` const diffResult = diff.createPatch(filePath, originalContent, newContent) if (diffResult) { await fs.writeFile(filePath, newContent) + this.say("tool", JSON.stringify({ tool: "editedExistingFile", path: filePath, diff: diffResult } as ClaudeSayTool)) return `Changes applied to ${filePath}:\n${diffResult}` } else { + this.say("tool", JSON.stringify({ tool: "editedExistingFile", path: filePath } as ClaudeSayTool)) return `Tool succeeded, however there were no changes detected to ${filePath}` } } else { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, newContent) + this.say("tool", JSON.stringify({ tool: "newFileCreated", path: filePath, content: newContent } as ClaudeSayTool)) return `New file created and content written to ${filePath}` } } catch (error) { const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}` - this.say("error", errorString) + this.say("error", JSON.stringify(serializeError(error))) return errorString } } async readFile(filePath: string): Promise { try { - return await fs.readFile(filePath, "utf-8") + const content = await fs.readFile(filePath, "utf-8") + this.say("tool", JSON.stringify({ tool: "readFile", path: filePath } as ClaudeSayTool)) + return content } catch (error) { const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}` - this.say("error", errorString) + this.say("error", JSON.stringify(serializeError(error))) return errorString } } - async listFiles(dirPath: string): Promise { + async listFiles(dirPath: string, shouldLog: boolean = true): Promise { // If the extension is run without a workspace open, we are in the root directory and don't want to list all files since it would prompt for permission to access everything const cwd = process.cwd() const root = process.platform === "win32" ? path.parse(cwd).root : "/" const isRoot = cwd === root if (isRoot) { + if (shouldLog) { + this.say("tool", JSON.stringify({ tool: "listFiles", path: dirPath } as ClaudeSayTool)) + } return "Currently in the root directory. Cannot list all files." } @@ -348,19 +356,19 @@ ${activeEditorContents}` } // * globs all files in one dir, ** globs files in nested directories const entries = await glob("*", options) + if (shouldLog) { + this.say("tool", JSON.stringify({ tool: "listFiles", path: dirPath } as ClaudeSayTool)) + } return entries.slice(0, 500).join("\n") // truncate to 500 entries } catch (error) { const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` - this.say("error", errorString) + this.say("error", JSON.stringify(serializeError(error))) return errorString } } async executeCommand(command: string): Promise { - const { response } = await this.ask( - "command", - `Claude wants to execute the following command:\n${command}\nDo you approve?` - ) + const { response } = await this.ask("command", command) if (response !== "yesButtonTapped") { return "Command execution was not approved by the user." } @@ -378,7 +386,7 @@ ${activeEditorContents}` const error = e as any let errorMessage = error.message || JSON.stringify(serializeError(error)) const errorString = `Error executing command:\n${errorMessage}` - this.say("error", errorString) + this.say("error", errorMessage) return errorString } } @@ -414,7 +422,7 @@ ${activeEditorContents}` if (this.requestCount >= this.maxRequestsPerTask) { const { response } = await this.ask( "request_limit_reached", - `\nClaude has exceeded ${this.maxRequestsPerTask} requests for this task! Would you like to reset the count and proceed?:` + `Claude Dev has reached the maximum number of requests for this task. Would you like to reset the count and allow him to proceed?` ) if (response === "yesButtonTapped") { @@ -434,7 +442,7 @@ ${activeEditorContents}` } try { - await this.say("api_req_started", JSON.stringify(userContent)) + await this.say("api_req_started", JSON.stringify({ request: userContent })) const response = await this.client.messages.create({ model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models max_tokens: 4096, @@ -448,7 +456,7 @@ ${activeEditorContents}` let assistantResponses: Anthropic.Messages.ContentBlock[] = [] let inputTokens = response.usage.input_tokens let outputTokens = response.usage.output_tokens - await this.say("api_req_finished", this.calculateApiCost(inputTokens, outputTokens).toString()) + await this.say("api_req_finished", JSON.stringify({ tokensIn: inputTokens, tokensOut: outputTokens, cost: this.calculateApiCost(inputTokens, outputTokens) })) // A response always returns text content blocks (it's just that before we were iterating over the completion_attempt response before we could append text response, resulting in bug) for (const contentBlock of response.content) { @@ -470,10 +478,10 @@ ${activeEditorContents}` attemptCompletionBlock = contentBlock } else { const result = await this.executeTool(toolName, toolInput) - this.say( - "tool", - `\nTool Used: ${toolName}\nTool Input: ${JSON.stringify(toolInput)}\nTool Result: ${result}` - ) + // this.say( + // "tool", + // `\nTool Used: ${toolName}\nTool Input: ${JSON.stringify(toolInput)}\nTool Result: ${result}` + // ) toolResults.push({ type: "tool_result", tool_use_id: toolUseId, content: result }) } } @@ -483,7 +491,7 @@ ${activeEditorContents}` this.conversationHistory.push({ role: "assistant", content: assistantResponses }) } else { // this should never happen! it there's no assistant_responses, that means we got no text or tool_use content blocks from API which we should assume is an error - this.say("error", "Error: No assistant responses found in API response!") + this.say("error", "Unexpected Error: No assistant messages were found in the API response") this.conversationHistory.push({ role: "assistant", content: [{ type: "text", text: "Failure: I did not have a response to provide." }], @@ -499,12 +507,12 @@ ${activeEditorContents}` attemptCompletionBlock.name as ToolName, attemptCompletionBlock.input ) - this.say( - "tool", - `\nattempt_completion Tool Used: ${attemptCompletionBlock.name}\nTool Input: ${JSON.stringify( - attemptCompletionBlock.input - )}\nTool Result: ${result}` - ) + // this.say( + // "tool", + // `\nattempt_completion Tool Used: ${attemptCompletionBlock.name}\nTool Input: ${JSON.stringify( + // attemptCompletionBlock.input + // )}\nTool Result: ${result}` + // ) if (result === "") { didCompleteTask = true result = "The user is satisfied with the result." @@ -539,7 +547,7 @@ ${activeEditorContents}` return { didCompleteTask, inputTokens, outputTokens } } catch (error) { // only called if the API request fails (executeTool errors are returned back to claude) - this.say("error", `Error calling Claude API: ${JSON.stringify(serializeError(error))}`) + this.say("error", JSON.stringify(serializeError(error))) return { didCompleteTask: true, inputTokens: 0, outputTokens: 0 } } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 1e5b0e4..86e6e2a 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -17,4 +17,11 @@ export interface ClaudeMessage { } export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result" -export type ClaudeSay = "task" | "error" | "api_req_started" | "api_req_finished" | "text" | "tool" | "command_output" | "completion_result" \ No newline at end of file +export type ClaudeSay = "task" | "error" | "api_req_started" | "api_req_finished" | "text" | "tool" | "command_output" | "completion_result" + +export interface ClaudeSayTool { + tool: "editedExistingFile" | "newFileCreated" | "readFile" | "listFiles" + path?: string + diff?: string + content?: string +} \ No newline at end of file diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index c61e58f..126e1bc 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,5 +1,5 @@ export interface WebviewMessage { - type: "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" | "newTask" | "askResponse" + type: "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" | "newTask" | "askResponse" | "abortTask" text?: string askResponse?: ClaudeAskResponse } diff --git a/webview-ui/src/components/ChatRow.tsx b/webview-ui/src/components/ChatRow.tsx index 112c38d..8d974d1 100644 --- a/webview-ui/src/components/ChatRow.tsx +++ b/webview-ui/src/components/ChatRow.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react" -import { ClaudeMessage, ClaudeAsk, ClaudeSay } from "@shared/ExtensionMessage" +import { ClaudeMessage, ClaudeAsk, ClaudeSay, ClaudeSayTool } from "@shared/ExtensionMessage" import { VSCodeButton, VSCodeProgressRing, VSCodeBadge } from "@vscode/webview-ui-toolkit/react" +import { COMMAND_OUTPUT_STRING } from "../utilities/combineCommandSequences" interface ChatRowProps { message: ClaudeMessage @@ -92,6 +93,7 @@ const ChatRow: React.FC = ({ message }) => { const contentStyle: React.CSSProperties = { margin: 0, + whiteSpace: "pre-line", } switch (message.type) { @@ -116,18 +118,58 @@ const ChatRow: React.FC = ({ message }) => { case "api_req_finished": return null // Hide this message type case "tool": + //const tool = JSON.parse(message.text || "{}") as ClaudeSayTool + const tool: ClaudeSayTool = { + tool: "editedExistingFile", + path: "/path/to/file", + } + switch (tool.tool) { + case "editedExistingFile": + return ( + <> +
+ {icon} + Edited File +
+

Path: {tool.path!}

+

{tool.diff!}

+ + ) + case "newFileCreated": + return ( + <> +
+ {icon} + Created New File +
+

Path: {tool.path!}

+

{tool.content!}

+ + ) + case "readFile": + return ( + <> +
+ {icon} + Read File +
+

Path: {tool.path!}

+ + ) + case "listFiles": + return ( + <> +
+ {icon} + Viewed Directory +
+

Path: {tool.path!}

+ + ) + } + break case "text": - return ( - <> - {title && ( -
- {icon} - {title} -
- )} -

{message.text}

- - ) + return

{message.text}

case "error": return ( <> @@ -167,6 +209,7 @@ const ChatRow: React.FC = ({ message }) => { ) } + break case "ask": switch (message.ask) { case "request_limit_reached": @@ -177,12 +220,23 @@ const ChatRow: React.FC = ({ message }) => { {title}

- Your task has reached the maximum request limit (maxRequestsPerTask, you can change - this in settings). Do you want to keep going or start a new task? + {message.text}

) case "command": + const splitMessage = (text: string) => { + const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING) + if (outputIndex === -1) { + return { command: text, output: "" } + } + return { + command: text.slice(0, outputIndex).trim(), + output: text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim(), + } + } + + const { command, output } = splitMessage(message.text || "") return ( <>
@@ -190,10 +244,16 @@ const ChatRow: React.FC = ({ message }) => { {title}
-

Claude would like to run this command. Do you allow this?

-
-										{message.text}
-									
+

Claude Dev wants to execute the following command:

+

{command}

+ {output && ( + <> +

+ {COMMAND_OUTPUT_STRING} +

+

{output}

+ + )}
) @@ -240,9 +300,7 @@ const ChatRow: React.FC = ({ message }) => { }}> {renderContent()} {isExpanded && message.say === "api_req_started" && ( -
-					{message.text}
-				
+

{JSON.stringify(JSON.parse(message.text || "{}").request)}

)} ) diff --git a/webview-ui/src/components/ChatView.tsx b/webview-ui/src/components/ChatView.tsx index 6a2408d..79b663c 100644 --- a/webview-ui/src/components/ChatView.tsx +++ b/webview-ui/src/components/ChatView.tsx @@ -33,7 +33,11 @@ const ChatView = ({ messages }: ChatViewProps) => { const scrollToBottom = (instant: boolean = false) => { // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move - (messagesEndRef.current as any)?.scrollIntoView({ behavior: instant ? "instant" : "smooth", block: "nearest", inline: "start" }) + ;(messagesEndRef.current as any)?.scrollIntoView({ + behavior: instant ? "instant" : "smooth", + block: "nearest", + inline: "start", + }) } const handlePrimaryButtonClick = () => { @@ -49,8 +53,19 @@ const ChatView = ({ messages }: ChatViewProps) => { setSecondaryButtonText(undefined) } + // scroll to bottom when new message is added + const visibleMessages = useMemo( + () => + modifiedMessages.filter( + (message) => !(message.type === "ask" && message.ask === "completion_result" && message.text === "") + ), + [modifiedMessages] + ) useEffect(() => { scrollToBottom() + }, [visibleMessages.length]) + + useEffect(() => { // if last message is an ask, show user ask UI // if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost. @@ -110,6 +125,10 @@ const ChatView = ({ messages }: ChatViewProps) => { } } + const handleTaskCloseButtonClick = () => { + vscode.postMessage({ type: "abortTask" }) + } + useEffect(() => { if (textAreaRef.current && !textAreaHeight) { setTextAreaHeight(textAreaRef.current.offsetHeight) @@ -158,6 +177,7 @@ const ChatView = ({ messages }: ChatViewProps) => { tokensIn={apiMetrics.totalTokensIn} tokensOut={apiMetrics.totalTokensOut} totalCost={apiMetrics.totalCost} + onClose={handleTaskCloseButtonClick} />
void } -const TaskHeader: React.FC = ({ taskText, tokensIn, tokensOut, totalCost }) => { +const TaskHeader: React.FC = ({ taskText, tokensIn, tokensOut, totalCost, onClose }) => { const [isExpanded, setIsExpanded] = useState(false) const toggleExpand = () => setIsExpanded(!isExpanded) return ( -
+
+
+ Task + + + +
= ({ taskText, tokensIn, tokensOut, )}
-
+
Tokens: -
- - - {tokensIn.toLocaleString()} - - - - {tokensOut.toLocaleString()} - -
+ + + {tokensIn.toLocaleString()} + + + + {tokensOut.toLocaleString()} +
-
+
API Cost: ${totalCost.toFixed(4)}
diff --git a/webview-ui/src/utilities/combineCommandSequences.ts b/webview-ui/src/utilities/combineCommandSequences.ts index 3259dbc..6601bb8 100644 --- a/webview-ui/src/utilities/combineCommandSequences.ts +++ b/webview-ui/src/utilities/combineCommandSequences.ts @@ -27,6 +27,7 @@ export function combineCommandSequences(messages: ClaudeMessage[]): ClaudeMessag for (let i = 0; i < messages.length; i++) { if (messages[i].type === "ask" && messages[i].ask === "command") { let combinedText = messages[i].text || "" + let didAddOutput = false let j = i + 1 while (j < messages.length) { @@ -35,6 +36,11 @@ export function combineCommandSequences(messages: ClaudeMessage[]): ClaudeMessag break } if (messages[j].type === "say" && messages[j].say === "command_output") { + if (!didAddOutput) { + // Add a newline before the first output + combinedText += `\n${COMMAND_OUTPUT_STRING}` + didAddOutput = true + } combinedText += "\n" + (messages[j].text || "") } j++ @@ -60,3 +66,4 @@ export function combineCommandSequences(messages: ClaudeMessage[]): ClaudeMessag return msg }) } +export const COMMAND_OUTPUT_STRING = "Output:"