mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Implement bidirectional extension webview messaging system; extension holds claude messages state to keep webview stateless
This commit is contained in:
28
package-lock.json
generated
28
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"execa": "^9.3.0",
|
"execa": "^9.3.0",
|
||||||
"glob": "^10.4.3",
|
"glob": "^10.4.3",
|
||||||
"os-name": "^6.0.0",
|
"os-name": "^6.0.0",
|
||||||
|
"p-wait-for": "^5.0.2",
|
||||||
"serialize-error": "^11.0.3"
|
"serialize-error": "^11.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -4465,6 +4466,33 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
"execa": "^9.3.0",
|
"execa": "^9.3.0",
|
||||||
"glob": "^10.4.3",
|
"glob": "^10.4.3",
|
||||||
"os-name": "^6.0.0",
|
"os-name": "^6.0.0",
|
||||||
|
"p-wait-for": "^5.0.2",
|
||||||
"serialize-error": "^11.0.3"
|
"serialize-error": "^11.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import * as path from "path"
|
|||||||
import { serializeError } from "serialize-error"
|
import { serializeError } from "serialize-error"
|
||||||
import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants"
|
import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants"
|
||||||
import { Tool, ToolName } from "./shared/Tool"
|
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.
|
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 conversationHistory: Anthropic.MessageParam[] = []
|
||||||
private maxRequestsPerTask: number
|
private maxRequestsPerTask: number
|
||||||
private requestCount = 0
|
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.client = new Anthropic({ apiKey })
|
||||||
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
|
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) {
|
updateApiKey(apiKey: string) {
|
||||||
this.client = new Anthropic({ apiKey })
|
this.client = new Anthropic({ apiKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMaxRequestsPerTask(maxRequestsPerTask: number) {
|
updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
|
||||||
this.maxRequestsPerTask = maxRequestsPerTask
|
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
|
||||||
}
|
}
|
||||||
|
|
||||||
async ask(type: "request_limit_reached" | "followup" | "command" | "completion_result", question: string): Promise<string> {
|
async handleWebviewAskResponse(askResponse: ClaudeAskResponse, text?: string) {
|
||||||
return ""
|
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> {
|
async say(type: ClaudeSay, question: string): Promise<undefined> {
|
||||||
// send message asyncronously
|
await this.providerRef.deref()?.addClaudeMessage({ type: "say", say: type, text: question })
|
||||||
return
|
await this.providerRef.deref()?.postStateToWebview()
|
||||||
}
|
}
|
||||||
|
|
||||||
async startNewTask(task: string): Promise<void> {
|
private async startTask(task: string): Promise<void> {
|
||||||
this.conversationHistory = []
|
|
||||||
this.requestCount = 0
|
|
||||||
// Get all relevant context for the task
|
// Get all relevant context for the task
|
||||||
const filesInCurrentDir = await this.listFiles()
|
const filesInCurrentDir = await this.listFiles()
|
||||||
|
|
||||||
@@ -209,7 +236,7 @@ ${filesInCurrentDir}`
|
|||||||
|
|
||||||
const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
|
const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
|
||||||
if (didCompleteTask) {
|
if (didCompleteTask) {
|
||||||
this.say("completed", `Task completed. Total API usage cost: ${totalCost}`)
|
this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
this.say(
|
this.say(
|
||||||
@@ -319,8 +346,9 @@ ${filesInCurrentDir}`
|
|||||||
mark: true, // Append a / on any directories matched
|
mark: true, // Append a / on any directories matched
|
||||||
}
|
}
|
||||||
// * globs all files in one dir, ** globs files in nested directories
|
// * globs all files in one dir, ** globs files in nested directories
|
||||||
const entries = await glob("**", options)
|
//const entries = await glob("**", options)
|
||||||
return entries.slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself)
|
// 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) {
|
} catch (error) {
|
||||||
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
|
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
|
||||||
this.say("error", errorString)
|
this.say("error", errorString)
|
||||||
@@ -329,8 +357,8 @@ ${filesInCurrentDir}`
|
|||||||
}
|
}
|
||||||
|
|
||||||
async executeCommand(command: string): Promise<string> {
|
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):`)
|
const { response } = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve?`)
|
||||||
if (answer.toLowerCase() !== "yes") {
|
if (response === "noButtonTapped") {
|
||||||
return "Command execution was not approved by the user."
|
return "Command execution was not approved by the user."
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -353,17 +381,17 @@ ${filesInCurrentDir}`
|
|||||||
}
|
}
|
||||||
|
|
||||||
async askFollowupQuestion(question: string): Promise<string> {
|
async askFollowupQuestion(question: string): Promise<string> {
|
||||||
const answer = await this.ask("followup", question)
|
const { text } = await this.ask("followup", question)
|
||||||
return `User's response:\n\"${answer}\"`
|
return `User's response:\n\"${text}\"`
|
||||||
}
|
}
|
||||||
|
|
||||||
async attemptCompletion(result: string): Promise<string> {
|
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):
|
// Are you satisfied with the result(yes/if no then provide feedback):
|
||||||
if (feedback.toLowerCase() === "yes") {
|
if (response === "yesButtonTapped") {
|
||||||
return ""
|
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(
|
async recursivelyMakeClaudeRequests(
|
||||||
@@ -376,12 +404,12 @@ ${filesInCurrentDir}`
|
|||||||
): Promise<ClaudeRequestResult> {
|
): Promise<ClaudeRequestResult> {
|
||||||
this.conversationHistory.push({ role: "user", content: userContent })
|
this.conversationHistory.push({ role: "user", content: userContent })
|
||||||
if (this.requestCount >= this.maxRequestsPerTask) {
|
if (this.requestCount >= this.maxRequestsPerTask) {
|
||||||
const answer = await this.ask(
|
const { response } = await this.ask(
|
||||||
"request_limit_reached",
|
"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
|
this.requestCount = 0
|
||||||
} else {
|
} else {
|
||||||
this.conversationHistory.push({
|
this.conversationHistory.push({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Uri, Webview } from "vscode"
|
import { Uri, Webview } from "vscode"
|
||||||
//import * as weather from "weather-js"
|
//import * as weather from "weather-js"
|
||||||
import * as vscode from "vscode"
|
import * as vscode from "vscode"
|
||||||
import { ExtensionMessage } from "../shared/ExtensionMessage"
|
import { ClaudeMessage, ExtensionMessage } from "../shared/ExtensionMessage"
|
||||||
import { WebviewMessage } from "../shared/WebviewMessage"
|
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
|
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 ExtensionSecretKey = "apiKey"
|
||||||
type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask"
|
type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask"
|
||||||
|
type ExtensionWorkspaceStateKey = "claudeMessages" | "apiConversationHistory"
|
||||||
|
|
||||||
export class SidebarProvider implements vscode.WebviewViewProvider {
|
export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||||
public static readonly viewType = "claude-dev.SidebarProvider"
|
public static readonly viewType = "claude-dev.SidebarProvider"
|
||||||
|
|
||||||
private _view?: vscode.WebviewView
|
private view?: vscode.WebviewView
|
||||||
|
private claudeDev?: ClaudeDev
|
||||||
|
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||||
|
|
||||||
@@ -25,7 +28,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
context: vscode.WebviewViewResolveContext<unknown>,
|
context: vscode.WebviewViewResolveContext<unknown>,
|
||||||
token: vscode.CancellationToken
|
token: vscode.CancellationToken
|
||||||
): void | Thenable<void> {
|
): void | Thenable<void> {
|
||||||
this._view = webviewView
|
this.view = webviewView
|
||||||
|
|
||||||
webviewView.webview.options = {
|
webviewView.webview.options = {
|
||||||
// Allow scripts in the webview
|
// 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
|
// 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
|
// 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
|
// Send any JSON serializable data to the react app
|
||||||
postMessageToWebview(message: ExtensionMessage) {
|
async postMessageToWebview(message: ExtensionMessage) {
|
||||||
this._view?.webview.postMessage(message)
|
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 webview A reference to the extension webview
|
||||||
* @param context A reference to the extension context
|
* @param context A reference to the extension context
|
||||||
*/
|
*/
|
||||||
private _setWebviewMessageListener(webview: vscode.Webview) {
|
private setWebviewMessageListener(webview: vscode.Webview) {
|
||||||
webview.onDidReceiveMessage(async (message: WebviewMessage) => {
|
webview.onDidReceiveMessage(async (message: WebviewMessage) => {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "webviewDidLaunch":
|
case "webviewDidLaunch":
|
||||||
await this.updateGlobalState("didOpenOnce", true)
|
await this.updateGlobalState("didOpenOnce", true)
|
||||||
await this.postStateToWebview()
|
await this.postStateToWebview()
|
||||||
break
|
break
|
||||||
case "text":
|
case "newTask":
|
||||||
// Code that should run in response to the hello message command
|
// 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.
|
// Send a message to our webview.
|
||||||
// You can send any JSON serializable data.
|
// You can send any JSON serializable data.
|
||||||
// Could also do this in extension .ts
|
// 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
|
break
|
||||||
case "apiKey":
|
case "apiKey":
|
||||||
await this.storeSecret("apiKey", message.text!)
|
await this.storeSecret("apiKey", message.text!)
|
||||||
|
this.claudeDev?.updateApiKey(message.text!)
|
||||||
await this.postStateToWebview()
|
await this.postStateToWebview()
|
||||||
break
|
break
|
||||||
case "maxRequestsPerTask":
|
case "maxRequestsPerTask":
|
||||||
@@ -160,25 +176,65 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.updateGlobalState("maxRequestsPerTask", result)
|
await this.updateGlobalState("maxRequestsPerTask", result)
|
||||||
|
this.claudeDev?.updateMaxRequestsPerTask(result)
|
||||||
await this.postStateToWebview()
|
await this.postStateToWebview()
|
||||||
break
|
break
|
||||||
|
case "askResponse":
|
||||||
|
this.claudeDev?.handleWebviewAskResponse(message.askResponse!, message.text)
|
||||||
// Add more switch case statements here as more webview message commands
|
// Add more switch case statements here as more webview message commands
|
||||||
// are created within the webview context (i.e. inside media/main.js)
|
// are created within the webview context (i.e. inside media/main.js)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postStateToWebview() {
|
async postStateToWebview() {
|
||||||
const [didOpenOnce, apiKey, maxRequestsPerTask] = await Promise.all([
|
const [didOpenOnce, apiKey, maxRequestsPerTask, claudeMessages] = await Promise.all([
|
||||||
this.getGlobalState("didOpenOnce") as Promise<boolean | undefined>,
|
this.getGlobalState("didOpenOnce") as Promise<boolean | undefined>,
|
||||||
this.getSecret("apiKey") as Promise<string | undefined>,
|
this.getSecret("apiKey") as Promise<string | undefined>,
|
||||||
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
|
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
|
||||||
|
this.getClaudeMessages(),
|
||||||
])
|
])
|
||||||
this.postMessageToWebview({
|
this.postMessageToWebview({
|
||||||
type: "state",
|
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
|
Storage
|
||||||
@@ -186,6 +242,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
https://www.eliostruyf.com/devhack-code-extension-storage-options/
|
https://www.eliostruyf.com/devhack-code-extension-storage-options/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// global
|
||||||
|
|
||||||
private async updateGlobalState(key: ExtensionGlobalStateKey, value: any) {
|
private async updateGlobalState(key: ExtensionGlobalStateKey, value: any) {
|
||||||
await this.context.globalState.update(key, value)
|
await this.context.globalState.update(key, value)
|
||||||
}
|
}
|
||||||
@@ -194,6 +252,18 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
return await this.context.globalState.get(key)
|
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) {
|
private async storeSecret(key: ExtensionSecretKey, value: any) {
|
||||||
await this.context.secrets.store(key, value)
|
await this.context.secrets.store(key, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface ClaudeRequestResult {
|
export interface ClaudeRequestResult {
|
||||||
didCompleteTask: boolean
|
didCompleteTask: boolean
|
||||||
inputTokens: number
|
inputTokens: number
|
||||||
outputTokens: number
|
outputTokens: number
|
||||||
|
|||||||
@@ -2,8 +2,18 @@
|
|||||||
|
|
||||||
// webview will hold state
|
// webview will hold state
|
||||||
export interface ExtensionMessage {
|
export interface ExtensionMessage {
|
||||||
type: "text" | "action" | "state"
|
type: "action" | "state"
|
||||||
text?: string
|
text?: string
|
||||||
action?: "plusButtonTapped" | "settingsButtonTapped"
|
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"
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
export interface WebviewMessage {
|
export interface WebviewMessage {
|
||||||
type: "text" | "action" | "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch"
|
type: "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" | "newTask" | "askResponse"
|
||||||
text?: string
|
text?: string
|
||||||
action?: "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "executeButtonTapped"
|
askResponse?: ClaudeAskResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClaudeAskResponse = "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "textResponse"
|
||||||
@@ -3,7 +3,7 @@ import "./App.css"
|
|||||||
|
|
||||||
import ChatView from "./components/ChatView"
|
import ChatView from "./components/ChatView"
|
||||||
import SettingsView from "./components/SettingsView"
|
import SettingsView from "./components/SettingsView"
|
||||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
import { ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
|
||||||
import WelcomeView from "./components/WelcomeView"
|
import WelcomeView from "./components/WelcomeView"
|
||||||
import { vscode } from "./utilities/vscode"
|
import { vscode } from "./utilities/vscode"
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ const App: React.FC = () => {
|
|||||||
const [showWelcome, setShowWelcome] = useState<boolean>(false)
|
const [showWelcome, setShowWelcome] = useState<boolean>(false)
|
||||||
const [apiKey, setApiKey] = useState<string>("")
|
const [apiKey, setApiKey] = useState<string>("")
|
||||||
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
|
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
|
||||||
|
const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
vscode.postMessage({ type: "webviewDidLaunch" })
|
vscode.postMessage({ type: "webviewDidLaunch" })
|
||||||
@@ -36,6 +37,7 @@ const App: React.FC = () => {
|
|||||||
? message.state!.maxRequestsPerTask.toString()
|
? message.state!.maxRequestsPerTask.toString()
|
||||||
: ""
|
: ""
|
||||||
)
|
)
|
||||||
|
setClaudeMessages(message.state!.claudeMessages)
|
||||||
break
|
break
|
||||||
case "action":
|
case "action":
|
||||||
switch (message.action!) {
|
switch (message.action!) {
|
||||||
@@ -64,7 +66,7 @@ const App: React.FC = () => {
|
|||||||
onDone={() => setShowSettings(false)}
|
onDone={() => setShowSettings(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatView />
|
<ChatView messages={claudeMessages} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||||
import { KeyboardEvent, useEffect, useRef, useState } from "react"
|
import { KeyboardEvent, useEffect, useRef, useState } from "react"
|
||||||
import DynamicTextArea from "react-textarea-autosize"
|
import DynamicTextArea from "react-textarea-autosize"
|
||||||
import { vscode } from "../utilities/vscode"
|
import { vscode } from "../utilities/vscode"
|
||||||
|
import { ClaudeAskResponse } from "@shared/WebviewMessage"
|
||||||
|
|
||||||
interface Message {
|
interface ChatViewProps {
|
||||||
id: string
|
messages: ClaudeMessage[]
|
||||||
text: string
|
|
||||||
sender: "user" | "assistant"
|
|
||||||
}
|
}
|
||||||
|
// 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 = () => {
|
const ChatView = ({ messages}: ChatViewProps) => {
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
|
||||||
const [inputValue, setInputValue] = useState("")
|
const [inputValue, setInputValue] = useState("")
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
|
const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
|
||||||
|
const [textAreaDisabled, setTextAreaDisabled] = useState(false)
|
||||||
|
|
||||||
|
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
// https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
|
// https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: 'nearest', inline: 'start' })
|
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 = () => {
|
const handleSendMessage = () => {
|
||||||
if (inputValue.trim()) {
|
const text = inputValue.trim()
|
||||||
const newMessage: Message = {
|
if (text) {
|
||||||
id: `${Date.now()}-user`,
|
|
||||||
text: inputValue.trim(),
|
|
||||||
sender: "user",
|
|
||||||
}
|
|
||||||
setMessages(currentMessages => [...currentMessages, newMessage])
|
|
||||||
setInputValue("")
|
setInputValue("")
|
||||||
// Here you would typically send the message to your extension's backend
|
if (messages.length === 0) {
|
||||||
vscode.postMessage({ type: "text", text: newMessage.text})
|
|
||||||
|
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>) => {
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -48,33 +81,21 @@ const ChatView = () => {
|
|||||||
if (textAreaRef.current && !textAreaHeight) {
|
if (textAreaRef.current && !textAreaHeight) {
|
||||||
setTextAreaHeight(textAreaRef.current.offsetHeight)
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
|
||||||
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
|
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
|
||||||
{messages.map((message) => (
|
{messages.map((message, index) => (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "10px",
|
marginBottom: "10px",
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
message.sender === "user"
|
message.type === "ask"
|
||||||
? "var(--vscode-editor-background)"
|
? "var(--vscode-editor-background)"
|
||||||
: "var(--vscode-sideBar-background)",
|
: "var(--vscode-sideBar-background)",
|
||||||
}}>
|
}}>
|
||||||
@@ -87,6 +108,7 @@ const ChatView = () => {
|
|||||||
<DynamicTextArea
|
<DynamicTextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
|
disabled={textAreaDisabled}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onHeightChange={() => scrollToBottom()}
|
onHeightChange={() => scrollToBottom()}
|
||||||
|
|||||||
Reference in New Issue
Block a user