Add ChatRow and handle different message types

This commit is contained in:
Saoud Rizwan
2024-07-08 15:37:50 -04:00
parent e713212e8c
commit 2ab7873e9c
4 changed files with 331 additions and 76 deletions

View File

@@ -11,7 +11,7 @@ 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 pWaitFor from "p-wait-for"
import { ClaudeAskResponse } from "./shared/WebviewMessage"
import { SidebarProvider } from "./providers/SidebarProvider"
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
@@ -159,54 +159,54 @@ export class ClaudeDev {
private conversationHistory: Anthropic.MessageParam[] = []
private maxRequestsPerTask: number
private requestCount = 0
private askResponse?: ClaudeAskResponse
private askResponseText?: string
private providerRef: WeakRef<SidebarProvider>
private askResponse?: ClaudeAskResponse
private askResponseText?: string
private providerRef: WeakRef<SidebarProvider>
constructor(provider: SidebarProvider, task: string, apiKey: string, maxRequestsPerTask?: number) {
this.providerRef = new WeakRef(provider)
this.providerRef = new WeakRef(provider)
this.client = new Anthropic({ apiKey })
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
this.startTask(task)
this.startTask(task)
}
updateApiKey(apiKey: string) {
this.client = new Anthropic({ apiKey })
}
updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
}
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
updateApiKey(apiKey: string) {
this.client = new Anthropic({ apiKey })
}
async say(type: ClaudeSay, question: string): Promise<undefined> {
await this.providerRef.deref()?.addClaudeMessage({ type: "say", say: type, text: question })
await this.providerRef.deref()?.postStateToWebview()
updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
}
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({ ts: Date.now(), 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: ClaudeSay, text: string): Promise<undefined> {
await this.providerRef.deref()?.addClaudeMessage({ ts: Date.now(), type: "say", say: type, text: text })
await this.providerRef.deref()?.postStateToWebview()
}
private async startTask(task: string): Promise<void> {
// 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)
// 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)
await this.providerRef.deref()?.setClaudeMessages([])
await this.providerRef.deref()?.postStateToWebview()
// Get all relevant context for the task
const filesInCurrentDir = await this.listFiles()
@@ -237,9 +237,9 @@ ${filesInCurrentDir}`
// The way this agentic loop works is that claude will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Claude is prompted to finish the task as efficiently as he can.
const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
//const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
if (didCompleteTask) {
this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
//this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
break
} else {
this.say(
@@ -272,13 +272,13 @@ ${filesInCurrentDir}`
}
// Calculates cost of a Claude 3.5 Sonnet API request
calculateApiCost(inputTokens: number, outputTokens: number): string {
calculateApiCost(inputTokens: number, outputTokens: number): number {
const INPUT_COST_PER_MILLION = 3.0 // $3 per million input tokens
const OUTPUT_COST_PER_MILLION = 15.0 // $15 per million output tokens
const inputCost = (inputTokens / 1_000_000) * INPUT_COST_PER_MILLION
const outputCost = (outputTokens / 1_000_000) * OUTPUT_COST_PER_MILLION
const totalCost = inputCost + outputCost
return `$${totalCost.toFixed(4)}`
return totalCost
}
async writeToFile(filePath: string, newContent: string): Promise<string> {
@@ -303,7 +303,7 @@ ${filesInCurrentDir}`
}
} catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString)
this.say("error", errorString)
return errorString
}
}
@@ -312,16 +312,16 @@ ${filesInCurrentDir}`
try {
return await fs.readFile(filePath, "utf-8")
} catch (error) {
const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString)
const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString)
return errorString
}
}
async listFiles(dirPath: string = "."): Promise<string> {
// 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 cwd = process.cwd()
const root = process.platform === "win32" ? path.parse(cwd).root : "/"
const isRoot = cwd === root
if (isRoot) {
return cwd
@@ -361,17 +361,20 @@ ${filesInCurrentDir}`
// 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 entries.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)
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString)
return errorString
}
}
async executeCommand(command: string): Promise<string> {
const { response } = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve?`)
if (response !== "yesButtonTapped") {
return "Command execution was not approved by the user."
}
const { response } = await this.ask(
"command",
`Claude wants to execute the following command:\n${command}\nDo you approve?`
)
if (response !== "yesButtonTapped") {
return "Command execution was not approved by the user."
}
try {
let result = ""
// execa by default tries to convery bash into javascript
@@ -385,8 +388,8 @@ ${filesInCurrentDir}`
} catch (e) {
const error = e as any
let errorMessage = error.message || JSON.stringify(serializeError(error))
const errorString = `Error executing command:\n${errorMessage}`
this.say("error", errorString)
const errorString = `Error executing command:\n${errorMessage}`
this.say("error", errorString)
return errorString
}
}
@@ -437,6 +440,7 @@ ${filesInCurrentDir}`
}
try {
await this.say("api_req_started", JSON.stringify(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,
@@ -450,7 +454,7 @@ ${filesInCurrentDir}`
let assistantResponses: Anthropic.Messages.ContentBlock[] = []
let inputTokens = response.usage.input_tokens
let outputTokens = response.usage.output_tokens
await this.say("api_cost", `API request cost: ${this.calculateApiCost(inputTokens, outputTokens)}`)
await this.say("api_req_finished", this.calculateApiCost(inputTokens, outputTokens).toString())
// 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) {

View File

@@ -9,6 +9,7 @@ export interface ExtensionMessage {
}
export interface ClaudeMessage {
ts: number
type: "ask" | "say"
ask?: ClaudeAsk
say?: ClaudeSay
@@ -16,4 +17,4 @@ export interface ClaudeMessage {
}
export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result"
export type ClaudeSay = "error" | "api_cost" | "text" | "tool" | "command_output" | "task_completed"
export type ClaudeSay = "task" | "error" | "api_req_started" | "api_req_finished" | "text" | "tool" | "command_output"

View File

@@ -0,0 +1,209 @@
import React, { useState } from "react"
import { ClaudeMessage, ClaudeAsk, ClaudeSay } from "@shared/ExtensionMessage"
import { VSCodeButton, VSCodeProgressRing, VSCodeTag } from "@vscode/webview-ui-toolkit/react"
interface ChatRowProps {
message: ClaudeMessage
cost?: string
}
const ChatRow: React.FC<ChatRowProps> = ({ message, cost }) => {
const [isExpanded, setIsExpanded] = useState(false)
const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, string | null] => {
switch (type) {
case "request_limit_reached":
return [
<span className="codicon codicon-error" style={{ color: "var(--vscode-errorForeground)" }}></span>,
"Max Requests Reached",
]
case "error":
return [
<span className="codicon codicon-error" style={{ color: "var(--vscode-errorForeground)" }}></span>,
"Error",
]
case "command":
return [<span className="codicon codicon-terminal"></span>, "Command"]
case "completion_result":
return [
<span
className="codicon codicon-check"
style={{ color: "var(--vscode-testing-iconPassed)" }}></span>,
"Task Completed",
]
case "tool":
return [<span className="codicon codicon-tools"></span>, "Tool"]
default:
return [null, null]
}
}
const renderContent = () => {
const [icon, title] = getIconAndTitle(message.type === "ask" ? message.ask : message.say)
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "left",
gap: "10px",
marginBottom: "10px",
}
const contentStyle: React.CSSProperties = {
marginLeft: "20px",
}
switch (message.type) {
case "say":
switch (message.say) {
case "task":
return (
<div
style={{
backgroundColor: "var(--vscode-textBlockQuote-background)",
padding: "10px",
borderLeft: "5px solid var(--vscode-textBlockQuote-border)",
}}>
<h3 style={headerStyle}>Task</h3>
<p style={contentStyle}>{message.text}</p>
</div>
)
case "api_req_started":
return (
<div>
<div style={headerStyle}>
<span>Made API request...</span>
{cost ? (
<span
className="codicon codicon-check"
style={{ color: "var(--vscode-testing-iconPassed)" }}></span>
) : (
<VSCodeProgressRing />
)}
{cost && <VSCodeTag>{cost}</VSCodeTag>}
</div>
<VSCodeButton
appearance="icon"
aria-label="Toggle Details"
onClick={() => setIsExpanded(!isExpanded)}>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</VSCodeButton>
</div>
)
case "api_req_finished":
return null // Hide this message type
case "tool":
case "error":
case "text":
case "command_output":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
)}
<pre style={contentStyle}>
<code>{message.text}</code>
</pre>
</>
)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
)}
<p style={contentStyle}>{message.text}</p>
</>
)
}
case "ask":
switch (message.ask) {
case "request_limit_reached":
return (
<>
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
<p style={{ ...contentStyle, color: "var(--vscode-errorForeground)" }}>
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?
</p>
</>
)
case "command":
return (
<>
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
<div style={contentStyle}>
<p>Claude would like to run this command. Do you allow this?</p>
<pre>
<code>{message.text}</code>
</pre>
</div>
</>
)
case "completion_result":
return (
<div
style={{
borderLeft: "5px solid var(--vscode-testing-iconPassed)",
paddingLeft: "10px",
}}>
<div style={headerStyle}>
{icon}
<h4 style={{ color: "var(--vscode-testing-iconPassed)" }}>{title}</h4>
</div>
<p style={contentStyle}>{message.text}</p>
</div>
)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
<h4>{title}</h4>
</div>
)}
<p style={contentStyle}>{message.text}</p>
</>
)
}
}
}
if (message.say === "api_req_finished") {
return null // Don't render anything for this message type
}
return (
<div
style={{
padding: "10px",
borderBottom: "1px solid var(--vscode-panel-border)",
backgroundColor:
message.say === "task"
? "var(--vscode-textBlockQuote-background)"
: "var(--vscode-editor-background)",
}}>
{renderContent()}
{isExpanded && message.say === "api_req_started" && (
<pre style={{ marginTop: "10px" }}>
<code>{message.text}</code>
</pre>
)}
</div>
)
}
export default ChatRow

View File

@@ -4,12 +4,60 @@ import { KeyboardEvent, useEffect, useRef, useState } from "react"
import DynamicTextArea from "react-textarea-autosize"
import { vscode } from "../utilities/vscode"
import { ClaudeAskResponse } from "@shared/WebviewMessage"
import ChatRow from "./ChatRow"
interface ChatViewProps {
messages: ClaudeMessage[]
}
// 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 ChatView = ({}: ChatViewProps) => {
// dummy data for messages
const generateRandomTimestamp = (baseDate: Date, rangeInDays: number): number => {
const rangeInMs = rangeInDays * 24 * 60 * 60 * 1000 // convert days to milliseconds
const randomOffset = Math.floor(Math.random() * rangeInMs * 2) - rangeInMs // rangeInMs * 2 to have offset in both directions
return baseDate.getTime() + randomOffset
}
const baseDate = new Date("2024-07-08T00:00:00Z")
const messages: ClaudeMessage[] = [
{ type: "say", say: "task", text: "type: say, say: task", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "ask",
ask: "request_limit_reached",
text: "type: ask, ask: request_limit_reached",
ts: generateRandomTimestamp(baseDate, 1),
},
{ type: "ask", ask: "followup", text: "type: ask, ask: followup", ts: generateRandomTimestamp(baseDate, 1) },
{ type: "ask", ask: "command", text: "type: ask, ask: command", ts: generateRandomTimestamp(baseDate, 1) },
{ type: "say", say: "error", text: "type: say, say: error", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "say",
say: "api_req_started",
text: "type: say, say: api_req_started",
ts: generateRandomTimestamp(baseDate, 1),
},
{
type: "say",
say: "api_req_finished",
text: "type: say, say: api_req_finished",
ts: generateRandomTimestamp(baseDate, 1),
},
{ type: "say", say: "text", text: "type: say, say: text", ts: generateRandomTimestamp(baseDate, 1) },
{ type: "say", say: "tool", text: "type: say, say: tool", ts: generateRandomTimestamp(baseDate, 1) },
{
type: "say",
say: "command_output",
text: "type: say, say: command_output",
ts: generateRandomTimestamp(baseDate, 1),
},
{
type: "ask",
ask: "completion_result",
text: "type: ask, ask: completion_result",
ts: generateRandomTimestamp(baseDate, 1),
},
]
const [inputValue, setInputValue] = useState("")
const messagesEndRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
@@ -20,14 +68,14 @@ const ChatView = ({ messages}: ChatViewProps) => {
const scrollToBottom = () => {
// 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()
// 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.
// 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)
@@ -42,14 +90,12 @@ const ChatView = ({ messages}: ChatViewProps) => {
}
}, [messages])
const handleSendMessage = () => {
const text = inputValue.trim()
if (text) {
setInputValue("")
if (messages.length === 0) {
vscode.postMessage({ type: "newTask", text })
vscode.postMessage({ type: "newTask", text })
} else if (claudeAsk) {
switch (claudeAsk) {
case "followup":
@@ -81,28 +127,23 @@ const ChatView = ({ messages}: ChatViewProps) => {
if (textAreaRef.current && !textAreaHeight) {
setTextAreaHeight(textAreaRef.current.offsetHeight)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
backgroundColor: "gray",
}}>
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
{messages.map((message, index) => (
<div
key={index}
style={{
marginBottom: "10px",
padding: "8px",
borderRadius: "4px",
backgroundColor:
message.type === "ask"
? "var(--vscode-editor-background)"
: "var(--vscode-sideBar-background)",
}}>
<span style={{ whiteSpace: "pre-line", overflowWrap: "break-word" }}>{message.text}</span>
</div>
{messages.map((message) => (
<ChatRow message={message} />
))}
<div style={{ float:"left", clear: "both" }} ref={messagesEndRef} />
<div style={{ float: "left", clear: "both" }} ref={messagesEndRef} />
</div>
<div style={{ position: "relative", paddingTop: "16px", paddingBottom: "16px" }}>
<DynamicTextArea