diff --git a/package-lock.json b/package-lock.json index 7ffff10..3826cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "execa": "^9.3.0", "glob": "^10.4.3", "os-name": "^6.0.0", + "p-wait-for": "^5.0.2", "serialize-error": "^11.0.3" }, "devDependencies": { @@ -4465,6 +4466,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.2.tgz", + "integrity": "sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-wait-for": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/p-wait-for/-/p-wait-for-5.0.2.tgz", + "integrity": "sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==", + "license": "MIT", + "dependencies": { + "p-timeout": "^6.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", diff --git a/package.json b/package.json index 9776bfd..5bc6cd7 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "execa": "^9.3.0", "glob": "^10.4.3", "os-name": "^6.0.0", + "p-wait-for": "^5.0.2", "serialize-error": "^11.0.3" } } diff --git a/src/ClaudeDev.ts b/src/ClaudeDev.ts index 756c80e..afd77be 100644 --- a/src/ClaudeDev.ts +++ b/src/ClaudeDev.ts @@ -9,6 +9,12 @@ 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 * as vscode from "vscode" +import pWaitFor from 'p-wait-for' +import { ClaudeAskResponse } from "./shared/WebviewMessage" +import { SidebarProvider } from "./providers/SidebarProvider" +import { ClaudeRequestResult } from "./shared/ClaudeRequestResult" const SYSTEM_PROMPT = `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. @@ -153,32 +159,53 @@ export class ClaudeDev { private conversationHistory: Anthropic.MessageParam[] = [] private maxRequestsPerTask: number private requestCount = 0 + private askResponse?: ClaudeAskResponse + private askResponseText?: string + private providerRef: WeakRef - constructor(apiKey: string, maxRequestsPerTask?: number) { + constructor(provider: SidebarProvider, task: string, apiKey: string, maxRequestsPerTask?: number) { + this.providerRef = new WeakRef(provider) this.client = new Anthropic({ apiKey }) this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK + + // 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) + this.providerRef.deref()?.setClaudeMessages([]) + + this.startTask(task) } updateApiKey(apiKey: string) { this.client = new Anthropic({ apiKey }) } - updateMaxRequestsPerTask(maxRequestsPerTask: number) { - this.maxRequestsPerTask = maxRequestsPerTask + updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) { + this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK } - async ask(type: "request_limit_reached" | "followup" | "command" | "completion_result", question: string): Promise { - return "" + async handleWebviewAskResponse(askResponse: ClaudeAskResponse, text?: string) { + this.askResponse = askResponse + this.askResponseText = text + } + + async ask(type: ClaudeAsk, question: string): Promise<{response: ClaudeAskResponse, text?: string}> { + this.askResponse = undefined + this.askResponseText = undefined + await this.providerRef.deref()?.addClaudeMessage({ type: "ask", ask: type, text: question }) + await this.providerRef.deref()?.postStateToWebview() + await pWaitFor(() => this.askResponse !== undefined, { interval: 100 }) + const result = { response: this.askResponse!, text: this.askResponseText } + this.askResponse = undefined + this.askResponseText = undefined + return result } - async say(type: "error" | "api_cost" | "text" | "tool" | "command_output" | "completed", question: string): Promise { - // send message asyncronously - return + async say(type: ClaudeSay, question: string): Promise { + await this.providerRef.deref()?.addClaudeMessage({ type: "say", say: type, text: question }) + await this.providerRef.deref()?.postStateToWebview() } - async startNewTask(task: string): Promise { - this.conversationHistory = [] - this.requestCount = 0 + private async startTask(task: string): Promise { // Get all relevant context for the task const filesInCurrentDir = await this.listFiles() @@ -209,7 +236,7 @@ ${filesInCurrentDir}` const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens) if (didCompleteTask) { - this.say("completed", `Task completed. Total API usage cost: ${totalCost}`) + this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`) break } else { this.say( @@ -319,8 +346,9 @@ ${filesInCurrentDir}` mark: true, // Append a / on any directories matched } // * globs all files in one dir, ** globs files in nested directories - const entries = await glob("**", options) - return entries.slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself) + //const entries = await glob("**", options) + // FIXME: instead of using glob to read all files, we will use vscode api to get workspace files list. (otherwise this prompts user to give permissions to read files if e.g. it was opened at root directory) + return ["index.ts"].slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself) } catch (error) { const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` this.say("error", errorString) @@ -329,8 +357,8 @@ ${filesInCurrentDir}` } async executeCommand(command: string): Promise { - const answer = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve? (yes/no):`) - if (answer.toLowerCase() !== "yes") { + const { response } = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve?`) + if (response === "noButtonTapped") { return "Command execution was not approved by the user." } try { @@ -353,17 +381,17 @@ ${filesInCurrentDir}` } async askFollowupQuestion(question: string): Promise { - const answer = await this.ask("followup", question) - return `User's response:\n\"${answer}\"` + const { text } = await this.ask("followup", question) + return `User's response:\n\"${text}\"` } async attemptCompletion(result: string): Promise { - const feedback = await this.ask("completion_result", result) + const { response, text } = await this.ask("completion_result", result) // Are you satisfied with the result(yes/if no then provide feedback): - if (feedback.toLowerCase() === "yes") { + if (response === "yesButtonTapped") { return "" } - return `The user is not pleased with the results. Use the feedback they provided to successfully complete the task, and then attempt completion again.\nUser's feedback:\n\"${feedback}\"` + return `The user is not pleased with the results. Use the feedback they provided to successfully complete the task, and then attempt completion again.\nUser's feedback:\n\"${text}\"` } async recursivelyMakeClaudeRequests( @@ -376,12 +404,12 @@ ${filesInCurrentDir}` ): Promise { this.conversationHistory.push({ role: "user", content: userContent }) if (this.requestCount >= this.maxRequestsPerTask) { - const answer = await this.ask( + 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? (yes/no):` + `\nClaude has exceeded ${this.maxRequestsPerTask} requests for this task! Would you like to reset the count and proceed?:` ) - if (answer.toLowerCase() === "yes") { + if (response === "yesButtonTapped") { this.requestCount = 0 } else { this.conversationHistory.push({ diff --git a/src/providers/SidebarProvider.ts b/src/providers/SidebarProvider.ts index dd69288..5e49529 100644 --- a/src/providers/SidebarProvider.ts +++ b/src/providers/SidebarProvider.ts @@ -1,8 +1,9 @@ import { Uri, Webview } from "vscode" //import * as weather from "weather-js" import * as vscode from "vscode" -import { ExtensionMessage } from "../shared/ExtensionMessage" +import { ClaudeMessage, ExtensionMessage } from "../shared/ExtensionMessage" import { WebviewMessage } from "../shared/WebviewMessage" +import { ClaudeDev } from "../ClaudeDev" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -12,11 +13,13 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c type ExtensionSecretKey = "apiKey" type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask" +type ExtensionWorkspaceStateKey = "claudeMessages" | "apiConversationHistory" export class SidebarProvider implements vscode.WebviewViewProvider { public static readonly viewType = "claude-dev.SidebarProvider" - private _view?: vscode.WebviewView + private view?: vscode.WebviewView + private claudeDev?: ClaudeDev constructor(private readonly context: vscode.ExtensionContext) {} @@ -25,7 +28,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken ): void | Thenable { - this._view = webviewView + this.view = webviewView webviewView.webview.options = { // Allow scripts in the webview @@ -36,12 +39,22 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is recieved - this._setWebviewMessageListener(webviewView.webview) + this.setWebviewMessageListener(webviewView.webview) + } + + async tryToInitClaudeDevWithTask(task: string) { + const [apiKey, maxRequestsPerTask] = await Promise.all([ + this.getSecret("apiKey") as Promise, + this.getGlobalState("maxRequestsPerTask") as Promise, + ]) + if (this.view && apiKey) { + this.claudeDev = new ClaudeDev(this, task, apiKey, maxRequestsPerTask) + } } // Send any JSON serializable data to the react app - postMessageToWebview(message: ExtensionMessage) { - this._view?.webview.postMessage(message) + async postMessageToWebview(message: ExtensionMessage) { + await this.view?.webview.postMessage(message) } /** @@ -131,24 +144,27 @@ export class SidebarProvider implements vscode.WebviewViewProvider { * @param webview A reference to the extension webview * @param context A reference to the extension context */ - private _setWebviewMessageListener(webview: vscode.Webview) { + private setWebviewMessageListener(webview: vscode.Webview) { webview.onDidReceiveMessage(async (message: WebviewMessage) => { switch (message.type) { case "webviewDidLaunch": await this.updateGlobalState("didOpenOnce", true) await this.postStateToWebview() break - case "text": + case "newTask": // Code that should run in response to the hello message command - vscode.window.showInformationMessage(message.text!) + //vscode.window.showInformationMessage(message.text!) // Send a message to our webview. // You can send any JSON serializable data. // Could also do this in extension .ts - this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` }) + //this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` }) + // initializing new instance of ClaudeDev will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task + await this.tryToInitClaudeDevWithTask(message.text!) break case "apiKey": await this.storeSecret("apiKey", message.text!) + this.claudeDev?.updateApiKey(message.text!) await this.postStateToWebview() break case "maxRequestsPerTask": @@ -160,25 +176,65 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } } await this.updateGlobalState("maxRequestsPerTask", result) + this.claudeDev?.updateMaxRequestsPerTask(result) await this.postStateToWebview() break + case "askResponse": + this.claudeDev?.handleWebviewAskResponse(message.askResponse!, message.text) // Add more switch case statements here as more webview message commands // are created within the webview context (i.e. inside media/main.js) } }) } - private async postStateToWebview() { - const [didOpenOnce, apiKey, maxRequestsPerTask] = await Promise.all([ + async postStateToWebview() { + const [didOpenOnce, apiKey, maxRequestsPerTask, claudeMessages] = await Promise.all([ this.getGlobalState("didOpenOnce") as Promise, this.getSecret("apiKey") as Promise, this.getGlobalState("maxRequestsPerTask") as Promise, + this.getClaudeMessages(), ]) this.postMessageToWebview({ type: "state", - state: { didOpenOnce: !!didOpenOnce, apiKey: apiKey, maxRequestsPerTask: maxRequestsPerTask }, + state: { didOpenOnce: !!didOpenOnce, apiKey, maxRequestsPerTask, claudeMessages }, }) } + + // client messages + + async getClaudeMessages(): Promise { + const messages = (await this.getWorkspaceState("claudeMessages")) as ClaudeMessage[] + return messages || [] + } + + async setClaudeMessages(messages: ClaudeMessage[] | undefined) { + await this.updateWorkspaceState("claudeMessages", messages) + } + + async addClaudeMessage(message: ClaudeMessage): Promise { + const messages = await this.getClaudeMessages() + messages.push(message) + await this.setClaudeMessages(messages) + return messages + } + + // api conversation history + + async getApiConversationHistory(): Promise { + const messages = (await this.getWorkspaceState("apiConversationHistory")) as ClaudeMessage[] + return messages || [] + } + + async setApiConversationHistory(messages: ClaudeMessage[] | undefined) { + await this.updateWorkspaceState("apiConversationHistory", messages) + } + + async addMessageToApiConversationHistory(message: ClaudeMessage): Promise { + const messages = await this.getClaudeMessages() + messages.push(message) + await this.setClaudeMessages(messages) + return messages + } /* Storage @@ -186,6 +242,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { https://www.eliostruyf.com/devhack-code-extension-storage-options/ */ + // global + private async updateGlobalState(key: ExtensionGlobalStateKey, value: any) { await this.context.globalState.update(key, value) } @@ -194,6 +252,18 @@ export class SidebarProvider implements vscode.WebviewViewProvider { return await this.context.globalState.get(key) } + // workspace + + private async updateWorkspaceState(key: ExtensionWorkspaceStateKey, value: any) { + await this.context.workspaceState.update(key, value) + } + + private async getWorkspaceState(key: ExtensionWorkspaceStateKey) { + return await this.context.workspaceState.get(key) + } + + // secrets + private async storeSecret(key: ExtensionSecretKey, value: any) { await this.context.secrets.store(key, value) } diff --git a/src/shared/ClaudeRequestResult.ts b/src/shared/ClaudeRequestResult.ts index 12216a4..545106c 100644 --- a/src/shared/ClaudeRequestResult.ts +++ b/src/shared/ClaudeRequestResult.ts @@ -1,4 +1,4 @@ -interface ClaudeRequestResult { +export interface ClaudeRequestResult { didCompleteTask: boolean inputTokens: number outputTokens: number diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 99347af..a127b9f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -2,8 +2,18 @@ // webview will hold state export interface ExtensionMessage { - type: "text" | "action" | "state" + type: "action" | "state" text?: string action?: "plusButtonTapped" | "settingsButtonTapped" - state?: { didOpenOnce: boolean, apiKey?: string, maxRequestsPerTask?: number } -} \ No newline at end of file + state?: { didOpenOnce: boolean, apiKey?: string, maxRequestsPerTask?: number, claudeMessages: ClaudeMessage[] } +} + +export interface ClaudeMessage { + type: "ask" | "say" + ask?: ClaudeAsk + say?: ClaudeSay + text?: string +} + +export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result" +export type ClaudeSay = "error" | "api_cost" | "text" | "tool" | "command_output" | "task_completed" \ No newline at end of file diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4939c02..c61e58f 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,5 +1,7 @@ export interface WebviewMessage { - type: "text" | "action" | "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" + type: "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" | "newTask" | "askResponse" text?: string - action?: "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "executeButtonTapped" -} \ No newline at end of file + askResponse?: ClaudeAskResponse +} + +export type ClaudeAskResponse = "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "textResponse" \ No newline at end of file diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 3df98e3..681fc21 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -3,7 +3,7 @@ import "./App.css" import ChatView from "./components/ChatView" import SettingsView from "./components/SettingsView" -import { ExtensionMessage } from "@shared/ExtensionMessage" +import { ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage" import WelcomeView from "./components/WelcomeView" import { vscode } from "./utilities/vscode" @@ -20,6 +20,7 @@ const App: React.FC = () => { const [showWelcome, setShowWelcome] = useState(false) const [apiKey, setApiKey] = useState("") const [maxRequestsPerTask, setMaxRequestsPerTask] = useState("") + const [claudeMessages, setClaudeMessages] = useState([]) useEffect(() => { vscode.postMessage({ type: "webviewDidLaunch" }) @@ -36,6 +37,7 @@ const App: React.FC = () => { ? message.state!.maxRequestsPerTask.toString() : "" ) + setClaudeMessages(message.state!.claudeMessages) break case "action": switch (message.action!) { @@ -64,7 +66,7 @@ const App: React.FC = () => { onDone={() => setShowSettings(false)} /> ) : ( - + )} ) diff --git a/webview-ui/src/components/ChatView.tsx b/webview-ui/src/components/ChatView.tsx index c24a19a..007604a 100644 --- a/webview-ui/src/components/ChatView.tsx +++ b/webview-ui/src/components/ChatView.tsx @@ -1,42 +1,75 @@ -import { ExtensionMessage } from "@shared/ExtensionMessage" +import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { KeyboardEvent, useEffect, useRef, useState } from "react" import DynamicTextArea from "react-textarea-autosize" import { vscode } from "../utilities/vscode" +import { ClaudeAskResponse } from "@shared/WebviewMessage" -interface Message { - id: string - text: string - sender: "user" | "assistant" +interface ChatViewProps { + messages: ClaudeMessage[] } - -const ChatView = () => { - const [messages, setMessages] = useState([]) +// maybe instead of storing state in App, just make chatview always show so dont conditionally load/unload? need to make sure messages are persisted (i remember seeing something about how webviews can be frozen in docs) +const ChatView = ({ messages}: ChatViewProps) => { const [inputValue, setInputValue] = useState("") const messagesEndRef = useRef(null) const textAreaRef = useRef(null) const [textAreaHeight, setTextAreaHeight] = useState(undefined) + const [textAreaDisabled, setTextAreaDisabled] = useState(false) + + const [claudeAsk, setClaudeAsk] = useState(undefined) const scrollToBottom = () => { // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: 'nearest', inline: 'start' }) } - useEffect(scrollToBottom, [messages]) + useEffect(() => { + scrollToBottom() + // 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. + // basically as long as a task is active, the conversation history will be persisted + + const lastMessage = messages.at(-1) + if (lastMessage) { + if (lastMessage.type === "ask") { + setClaudeAsk(lastMessage.ask) + //setTextAreaDisabled(false) // should enable for certain asks + } else { + setClaudeAsk(undefined) + //setTextAreaDisabled(true) + } + } + }, [messages]) + const handleSendMessage = () => { - if (inputValue.trim()) { - const newMessage: Message = { - id: `${Date.now()}-user`, - text: inputValue.trim(), - sender: "user", - } - setMessages(currentMessages => [...currentMessages, newMessage]) + const text = inputValue.trim() + if (text) { setInputValue("") - // Here you would typically send the message to your extension's backend - vscode.postMessage({ type: "text", text: newMessage.text}) + if (messages.length === 0) { + + vscode.postMessage({ type: "newTask", text }) + } else if (claudeAsk) { + switch (claudeAsk) { + case "followup": + vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text }) + break + // case "completion_result": + // vscode.postMessage({ type: "askResponse", text }) + // break + default: + // for now we'll type the askResponses + vscode.postMessage({ type: "askResponse", askResponse: text as ClaudeAskResponse }) + break + } + } } } + + // handle ask buttons + // be sure to setInputValue("") + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() @@ -48,33 +81,21 @@ const ChatView = () => { if (textAreaRef.current && !textAreaHeight) { setTextAreaHeight(textAreaRef.current.offsetHeight) } - - window.addEventListener("message", (e: MessageEvent) => { - const message: ExtensionMessage = e.data - if (message.type === "text") { - const newMessage: Message = { - id: `${Date.now()}-assistant`, - text: message.text!.trim(), - sender: "assistant", - } - setMessages(currentMessages => [...currentMessages, newMessage]) - } - }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return (
- {messages.map((message) => ( + {messages.map((message, index) => (
@@ -87,6 +108,7 @@ const ChatView = () => { setInputValue(e.target.value)} onKeyDown={handleKeyDown} onHeightChange={() => scrollToBottom()}