Implement bidirectional extension webview messaging system; extension holds claude messages state to keep webview stateless

This commit is contained in:
Saoud Rizwan
2024-07-08 12:58:05 -04:00
parent 09559c314b
commit 4da785b822
9 changed files with 242 additions and 79 deletions

28
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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<SidebarProvider>
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<string> {
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<undefined> {
// send message asyncronously
return
async say(type: ClaudeSay, question: string): Promise<undefined> {
await this.providerRef.deref()?.addClaudeMessage({ type: "say", say: type, text: question })
await this.providerRef.deref()?.postStateToWebview()
}
async startNewTask(task: string): Promise<void> {
this.conversationHistory = []
this.requestCount = 0
private async startTask(task: string): Promise<void> {
// 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<string> {
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<string> {
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<string> {
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<ClaudeRequestResult> {
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({

View File

@@ -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<unknown>,
token: vscode.CancellationToken
): void | Thenable<void> {
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<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
])
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<boolean | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
this.getClaudeMessages(),
])
this.postMessageToWebview({
type: "state",
state: { didOpenOnce: !!didOpenOnce, apiKey: apiKey, maxRequestsPerTask: maxRequestsPerTask },
state: { didOpenOnce: !!didOpenOnce, apiKey, maxRequestsPerTask, claudeMessages },
})
}
// client messages
async getClaudeMessages(): Promise<ClaudeMessage[]> {
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<ClaudeMessage[]> {
const messages = await this.getClaudeMessages()
messages.push(message)
await this.setClaudeMessages(messages)
return messages
}
// api conversation history
async getApiConversationHistory(): Promise<ClaudeMessage[]> {
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<ClaudeMessage[]> {
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)
}

View File

@@ -1,4 +1,4 @@
interface ClaudeRequestResult {
export interface ClaudeRequestResult {
didCompleteTask: boolean
inputTokens: number
outputTokens: number

View File

@@ -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 }
}
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"

View File

@@ -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"
}
askResponse?: ClaudeAskResponse
}
export type ClaudeAskResponse = "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "textResponse"

View File

@@ -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<boolean>(false)
const [apiKey, setApiKey] = useState<string>("")
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([])
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)}
/>
) : (
<ChatView />
<ChatView messages={claudeMessages} />
)}
</>
)

View File

@@ -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<Message[]>([])
// 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<HTMLDivElement>(null)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
const [textAreaDisabled, setTextAreaDisabled] = useState(false)
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(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<HTMLTextAreaElement>) => {
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
{messages.map((message) => (
{messages.map((message, index) => (
<div
key={message.id}
key={index}
style={{
marginBottom: "10px",
padding: "8px",
borderRadius: "4px",
backgroundColor:
message.sender === "user"
message.type === "ask"
? "var(--vscode-editor-background)"
: "var(--vscode-sideBar-background)",
}}>
@@ -87,6 +108,7 @@ const ChatView = () => {
<DynamicTextArea
ref={textAreaRef}
value={inputValue}
disabled={textAreaDisabled}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onHeightChange={() => scrollToBottom()}