From a6c64bea8fa16d272c481ea10ae24e6d11ff3d6a Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 8 Sep 2024 18:06:52 -0400 Subject: [PATCH] Add terminal output and diagnostics to relevant details --- src/ClaudeDev.ts | 307 ++++++++++++----------- src/api/anthropic.ts | 20 +- src/api/bedrock.ts | 20 +- src/api/index.ts | 37 --- src/api/ollama.ts | 19 +- src/api/openai.ts | 19 +- src/api/openrouter.ts | 20 +- src/api/vertex.ts | 20 +- src/integrations/TerminalManager.ts | 229 +++++++---------- src/parse-source-code/index.ts | 2 +- src/utils/export-markdown.ts | 2 +- webview-ui/src/components/ChatRow.tsx | 41 +-- webview-ui/src/components/CodeBlock.tsx | 37 ++- webview-ui/src/components/TaskHeader.tsx | 2 +- 14 files changed, 320 insertions(+), 455 deletions(-) diff --git a/src/ClaudeDev.ts b/src/ClaudeDev.ts index 3f6f891..9744eb9 100644 --- a/src/ClaudeDev.ts +++ b/src/ClaudeDev.ts @@ -21,7 +21,7 @@ import { getApiMetrics } from "./shared/getApiMetrics" import { HistoryItem } from "./shared/HistoryItem" import { Tool, ToolName } from "./shared/Tool" import { ClaudeAskResponse } from "./shared/WebviewMessage" -import { findLast, findLastIndex } from "./utils" +import { findLast, findLastIndex, formatContentBlockToMarkdown } from "./utils" import { truncateHalfConversation } from "./utils/context-management" import { regexSearchFiles } from "./utils/ripgrep" import { extractTextFromFile } from "./utils/extract-text" @@ -281,7 +281,7 @@ export class ClaudeDev { ) { this.providerRef = new WeakRef(provider) this.api = buildApiHandler(apiConfiguration) - this.terminalManager = new TerminalManager(provider.context) + this.terminalManager = new TerminalManager() this.customInstructions = customInstructions this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false @@ -445,31 +445,6 @@ export class ClaudeDev { await this.providerRef.deref()?.postStateToWebview() } - private formatImagesIntoBlocks(images?: string[]): Anthropic.ImageBlockParam[] { - return images - ? images.map((dataUrl) => { - // data:image/png;base64,base64string - const [rest, base64] = dataUrl.split(",") - const mimeType = rest.split(":")[1].split(";")[0] - return { - type: "image", - source: { type: "base64", media_type: mimeType, data: base64 }, - } as Anthropic.ImageBlockParam - }) - : [] - } - - private formatIntoToolResponse(text: string, images?: string[]): ToolResponse { - if (images && images.length > 0) { - const textBlock: Anthropic.TextBlockParam = { type: "text", text } - const imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images) - // Placing images after text leads to better results - return [textBlock, ...imageBlocks] - } else { - return text - } - } - private async startTask(task?: string, images?: string[]): Promise { // 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) @@ -486,21 +461,19 @@ export class ClaudeDev { await this.say( "api_req_started", JSON.stringify({ - request: this.api.createUserReadableRequest([ - { - type: "text", - text: `${taskText}\n\n(see getPotentiallyRelevantDetails in src/ClaudeDev.ts)`, - }, - ...imageBlocks, - ]), + request: `${taskText}\n\n\nLoading...\n`, }) ) this.shouldSkipNextApiReqStartedMessage = true - this.getPotentiallyRelevantDetails(true).then(async (verboseDetails) => { + this.getInitialDetails().then(async (initialDetails) => { + const lastApiReqIndex = findLastIndex(this.claudeMessages, (m) => m.say === "api_req_started") + this.claudeMessages[lastApiReqIndex].text = JSON.stringify({ request: `${taskText}\n\n${initialDetails}` }) + await this.saveClaudeMessages() + await this.providerRef.deref()?.postStateToWebview() await this.initiateTaskLoop([ { type: "text", - text: `${taskText}\n\n${verboseDetails}`, // cannot be sent with system prompt since it's cached and these details can change + text: `${taskText}\n\n${initialDetails}`, // cannot be sent with system prompt since it's cached and these details can change }, ...imageBlocks, ]) @@ -687,8 +660,7 @@ export class ClaudeDev { : "") + (newUserContentText ? `\n\nNew instructions for task continuation:\n\n${newUserContentText}\n\n` - : "") + - `\n\n${await this.getPotentiallyRelevantDetails()}` + : "") const newUserContentImages = newUserContent.filter((block) => block.type === "image") const combinedModifiedOldUserContentWithNewUserContent: UserContent = ( @@ -781,21 +753,12 @@ export class ClaudeDev { async writeToFile(relPath?: string, newContent?: string): Promise { if (relPath === undefined) { - await this.say( - "error", - "Claude tried to use write_to_file without value for required parameter 'path'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'path'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("write_to_file", "path") } - if (newContent === undefined) { - await this.say( - "error", - `Claude tried to use write_to_file for '${relPath}' without value for required parameter 'content'. This is likely due to output token limits. Retrying...` - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'content'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("write_to_file", "content", relPath) } this.consecutiveMistakeCount = 0 try { @@ -991,9 +954,9 @@ export class ClaudeDev { if (response === "messageResponse") { await this.say("user_feedback", text, images) - return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images) + return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images) } - return "The user denied this operation." + return await this.formatToolDenied() } const editedContent = updatedDocument.getText() @@ -1070,9 +1033,11 @@ export class ClaudeDev { diff: this.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent), } as ClaudeSayTool) ) - return `The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath}. Note this does not mean you need to re-write the file with the user's changes, they have already been applied to the file.` + return this.formatToolResult( + `The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath}. Note this does not mean you need to re-write the file with the user's changes, they have already been applied to the file.` + ) } else { - return `The content was successfully saved to ${relPath}.` + return this.formatToolResult(`The content was successfully saved to ${relPath}.`) } } catch (error) { const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}` @@ -1080,7 +1045,7 @@ export class ClaudeDev { "error", `Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` ) - return errorString + return await this.formatToolError(errorString) } } @@ -1152,12 +1117,8 @@ export class ClaudeDev { async readFile(relPath?: string): Promise { if (relPath === undefined) { - await this.say( - "error", - "Claude tried to use read_file without value for required parameter 'path'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'path'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("read_file", "path") } this.consecutiveMistakeCount = 0 try { @@ -1176,9 +1137,9 @@ export class ClaudeDev { if (response !== "yesButtonTapped") { if (response === "messageResponse") { await this.say("user_feedback", text, images) - return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images) + return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images) } - return "The user denied this operation." + return await this.formatToolDenied() } } @@ -1189,18 +1150,14 @@ export class ClaudeDev { "error", `Error reading file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` ) - return errorString + return await this.formatToolError(errorString) } } async listFiles(relDirPath?: string, recursiveRaw?: string): Promise { if (relDirPath === undefined) { - await this.say( - "error", - "Claude tried to use list_files without value for required parameter 'path'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'path'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("list_files", "path") } this.consecutiveMistakeCount = 0 try { @@ -1221,13 +1178,13 @@ export class ClaudeDev { if (response !== "yesButtonTapped") { if (response === "messageResponse") { await this.say("user_feedback", text, images) - return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images) + return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images) } - return "The user denied this operation." + return await this.formatToolDenied() } } - return result + return this.formatToolResult(result) } catch (error) { const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` await this.say( @@ -1236,7 +1193,7 @@ export class ClaudeDev { error.message ?? JSON.stringify(serializeError(error), null, 2) }` ) - return errorString + return await this.formatToolError(errorString) } } @@ -1301,12 +1258,8 @@ export class ClaudeDev { async listCodeDefinitionNames(relDirPath?: string): Promise { if (relDirPath === undefined) { - await this.say( - "error", - "Claude tried to use list_code_definition_names without value for required parameter 'path'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'path'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("list_code_definition_names", "path") } this.consecutiveMistakeCount = 0 try { @@ -1325,13 +1278,13 @@ export class ClaudeDev { if (response !== "yesButtonTapped") { if (response === "messageResponse") { await this.say("user_feedback", text, images) - return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images) + return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images) } - return "The user denied this operation." + return await this.formatToolDenied() } } - return result + return this.formatToolResult(result) } catch (error) { const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}` await this.say( @@ -1340,26 +1293,18 @@ export class ClaudeDev { error.message ?? JSON.stringify(serializeError(error), null, 2) }` ) - return errorString + return await this.formatToolError(errorString) } } async searchFiles(relDirPath: string, regex: string, filePattern?: string): Promise { if (relDirPath === undefined) { - await this.say( - "error", - "Claude tried to use search_files without value for required parameter 'path'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'path'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("search_files", "path") } if (regex === undefined) { - await this.say( - "error", - `Claude tried to use search_files without value for required parameter 'regex'. Retrying...` - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'regex'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("search_files", "regex", relDirPath) } this.consecutiveMistakeCount = 0 try { @@ -1381,40 +1326,36 @@ export class ClaudeDev { if (response !== "yesButtonTapped") { if (response === "messageResponse") { await this.say("user_feedback", text, images) - return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images) + return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images) } - return "The user denied this operation." + return await this.formatToolDenied() } } - return results + return this.formatToolResult(results) } catch (error) { const errorString = `Error searching files: ${JSON.stringify(serializeError(error))}` await this.say( "error", `Error searching files:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` ) - return errorString + return await this.formatToolError(errorString) } } async executeCommand(command?: string, returnEmptyStringOnSuccess: boolean = false): Promise { if (command === undefined) { - await this.say( - "error", - "Claude tried to use execute_command without value for required parameter 'command'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'command'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("execute_command", "command") } this.consecutiveMistakeCount = 0 const { response, text, images } = await this.ask("command", command) if (response !== "yesButtonTapped") { if (response === "messageResponse") { await this.say("user_feedback", text, images) - return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images) + return this.formatToolResponseWithImages(await this.formatToolDeniedFeedback(text), images) } - return "The user denied this operation." + return await this.formatToolDenied() } try { @@ -1439,7 +1380,7 @@ export class ClaudeDev { let result = "" process.on("line", (line) => { - console.log("sending line from here", line) + console.log("New line from process:", line) result += line sendCommandOutput(line) }) @@ -1455,10 +1396,8 @@ export class ClaudeDev { if (userFeedback) { await this.say("user_feedback", userFeedback.text, userFeedback.images) - return this.formatIntoToolResponse( - `Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n\n${ - userFeedback.text - }\n\n\n${await this.getPotentiallyRelevantDetails()}`, + return this.formatToolResponseWithImages( + `Command Output:\n${result}\n\nThe user interrupted the command and provided the following feedback:\n\n${userFeedback.text}\n`, userFeedback.images ) } @@ -1467,39 +1406,31 @@ export class ClaudeDev { if (returnEmptyStringOnSuccess) { return "" } - return `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}` + return await this.formatToolResult(`Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`) } catch (error) { let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2) const errorString = `Error executing command:\n${errorMessage}` await this.say("error", `Error executing command:\n${errorMessage}`) - return errorString + return await this.formatToolError(errorString) } } async askFollowupQuestion(question?: string): Promise { if (question === undefined) { - await this.say( - "error", - "Claude tried to use ask_followup_question without value for required parameter 'question'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'question'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("ask_followup_question", "question") } this.consecutiveMistakeCount = 0 const { text, images } = await this.ask("followup", question) await this.say("user_feedback", text ?? "", images) - return this.formatIntoToolResponse(`\n${text}\n`, images) + return this.formatToolResponseWithImages(`\n${text}\n`, images) } async attemptCompletion(result?: string, command?: string): Promise { // result is required, command is optional if (result === undefined) { - await this.say( - "error", - "Claude tried to use attempt_completion without value for required parameter 'result'. Retrying..." - ) this.consecutiveMistakeCount++ - return "Error: Missing value for required parameter 'result'. Please retry with complete response." + return await this.sayAndCreateMissingParamError("attempt_completion", "result") } this.consecutiveMistakeCount = 0 let resultToSend = result @@ -1518,8 +1449,8 @@ export class ClaudeDev { return "" // signals to recursive loop to stop (for now this never happens since yesButtonTapped will trigger a new task) } await this.say("user_feedback", text ?? "", images) - return this.formatIntoToolResponse( - `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n\n\n${await this.getPotentiallyRelevantDetails()}`, + return this.formatToolResponseWithImages( + `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n\n${text}\n`, images ) } @@ -1600,7 +1531,7 @@ ${this.customInstructions.trim()} ...[ { type: "text", - text: `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n\n${text}\n\n\n${await this.getPotentiallyRelevantDetails()}`, + text: `You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n\n${text}\n`, } as Anthropic.Messages.TextBlockParam, ...this.formatImagesIntoBlocks(images), ] @@ -1609,15 +1540,15 @@ ${this.customInstructions.trim()} this.consecutiveMistakeCount = 0 } + // add potentially relevant details as its own text block, separate from tool results + userContent.push({ type: "text", text: await this.getPotentiallyRelevantDetails() }) + await this.addToApiConversationHistory({ role: "user", content: userContent }) if (!this.shouldSkipNextApiReqStartedMessage) { await this.say( "api_req_started", - // what the user sees in the webview - JSON.stringify({ - request: this.api.createUserReadableRequest(userContent), - }) + JSON.stringify({ request: userContent.map(formatContentBlockToMarkdown).join("\n\n") }) ) } else { this.shouldSkipNextApiReqStartedMessage = false @@ -1748,9 +1679,50 @@ ${this.customInstructions.trim()} } } - // Prompts + // Formatting responses to Claude - async getPotentiallyRelevantDetails(verbose: boolean = false) { + private formatImagesIntoBlocks(images?: string[]): Anthropic.ImageBlockParam[] { + return images + ? images.map((dataUrl) => { + // data:image/png;base64,base64string + const [rest, base64] = dataUrl.split(",") + const mimeType = rest.split(":")[1].split(";")[0] + return { + type: "image", + source: { type: "base64", media_type: mimeType, data: base64 }, + } as Anthropic.ImageBlockParam + }) + : [] + } + + private formatToolResponseWithImages(text: string, images?: string[]): ToolResponse { + if (images && images.length > 0) { + const textBlock: Anthropic.TextBlockParam = { type: "text", text } + const imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images) + // Placing images after text leads to better results + return [textBlock, ...imageBlocks] + } else { + return text + } + } + + async getInitialDetails() { + let details = "" + + const isDesktop = cwd === path.join(os.homedir(), "Desktop") + const files = await listFiles(cwd, !isDesktop) + const result = this.formatFilesList(cwd, files) + details += `\n# Current Working Directory ('${cwd}') File Structure:${ + isDesktop + ? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)" + : "" + }\n${result}\n` + + details += "" + return details + } + + async getPotentiallyRelevantDetails() { let details = ` # VSCode Visible Files: ${ @@ -1769,25 +1741,76 @@ ${ .filter(Boolean) .map((absolutePath) => path.relative(cwd, absolutePath)) .join("\n") || "(No tabs open)" -} -` +}` - if (verbose) { - const isDesktop = cwd === path.join(os.homedir(), "Desktop") - const files = await listFiles(cwd, !isDesktop) - const result = this.formatFilesList(cwd, files) - details += `\n# Current Working Directory ('${cwd}') File Structure:${ - isDesktop - ? "\n(Desktop so only top-level contents shown for brevity, use list_files to explore further if necessary)" - : "" - }:\n${result}\n` + const busyTerminals = this.terminalManager.getBusyTerminals() + if (busyTerminals.length > 0) { + details += "\n\n# Active Terminals:" + for (const busyTerminal of busyTerminals) { + details += `\n## Original command:\n${busyTerminal.lastCommand}` + const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id) + if (newOutput) { + details += `\n## New output since last check:\n${newOutput}` + } + } } - details += "" + // Get diagnostics for all open files in the workspace + const diagnostics = vscode.languages.getDiagnostics() + const relevantDiagnostics = diagnostics.filter(([_, fileDiagnostics]) => + fileDiagnostics.some( + (d) => + d.severity === vscode.DiagnosticSeverity.Error || d.severity === vscode.DiagnosticSeverity.Warning + ) + ) + + if (relevantDiagnostics.length > 0) { + details += "\n\n# Workspace Diagnostics:" + for (const [uri, fileDiagnostics] of relevantDiagnostics) { + const relativePath = path.relative(cwd, uri.fsPath) + details += `\n## ${relativePath}:` + for (const diagnostic of fileDiagnostics) { + if ( + diagnostic.severity === vscode.DiagnosticSeverity.Error || + diagnostic.severity === vscode.DiagnosticSeverity.Warning + ) { + let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning" + const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed + details += `\n- [${severity}] Line ${line}: ${diagnostic.message}` + } + } + } + } + + details += "\n" return details } - async formatGenericToolFeedback(feedback?: string) { - return `The user denied this operation and provided the following feedback:\n\n${feedback}\n\n\n${await this.getPotentiallyRelevantDetails()}` + async formatToolDeniedFeedback(feedback?: string) { + return `The user denied this operation and provided the following feedback:\n\n${feedback}\n` + } + + async formatToolDenied() { + return `The user denied this operation.` + } + + async formatToolResult(result: string) { + return result // the successful result of the tool should never be manipulated, if we need to add details it should be as a separate user text block + } + + async formatToolError(error?: string) { + return `The tool execution failed with the following error:\n\n${error}\n` + } + + async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) { + await this.say( + "error", + `Claude tried to use ${toolName}${ + relPath ? ` for '${relPath}'` : "" + } without value for required parameter '${paramName}'. Retrying...` + ) + return await this.formatToolError( + `Missing value for required parameter '${paramName}'. Please retry with complete response.` + ) } } diff --git a/src/api/anthropic.ts b/src/api/anthropic.ts index a5368c5..bc7f5ee 100644 --- a/src/api/anthropic.ts +++ b/src/api/anthropic.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." +import { ApiHandler, ApiHandlerMessageResponse } from "." import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../shared/api" export class AnthropicHandler implements ApiHandler { @@ -99,24 +99,6 @@ export class AnthropicHandler implements ApiHandler { } } - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any { - return { - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens, - system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)", - messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }], - tools: "(see tools in src/ClaudeDev.ts)", - tool_choice: { type: "auto" }, - } - } - getModel(): { id: AnthropicModelId; info: ModelInfo } { const modelId = this.options.apiModelId if (modelId && modelId in anthropicModels) { diff --git a/src/api/bedrock.ts b/src/api/bedrock.ts index d979a94..9bca9d1 100644 --- a/src/api/bedrock.ts +++ b/src/api/bedrock.ts @@ -1,6 +1,6 @@ import AnthropicBedrock from "@anthropic-ai/bedrock-sdk" import { Anthropic } from "@anthropic-ai/sdk" -import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." +import { ApiHandler, ApiHandlerMessageResponse } from "." import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../shared/api" // https://docs.anthropic.com/en/api/claude-on-amazon-bedrock @@ -39,24 +39,6 @@ export class AwsBedrockHandler implements ApiHandler { return { message } } - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any { - return { - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens, - system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)", - messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }], - tools: "(see tools in src/ClaudeDev.ts)", - tool_choice: { type: "auto" }, - } - } - getModel(): { id: BedrockModelId; info: ModelInfo } { const modelId = this.options.apiModelId if (modelId && modelId in bedrockModels) { diff --git a/src/api/index.ts b/src/api/index.ts index 40aeb80..9f06ccf 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,15 +19,6 @@ export interface ApiHandler { tools: Anthropic.Messages.Tool[] ): Promise - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any - getModel(): { id: string; info: ModelInfo } } @@ -50,31 +41,3 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new AnthropicHandler(options) } } - -export function withoutImageData( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > -): Array< - Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam -> { - return userContent.map((part) => { - if (part.type === "image") { - return { ...part, source: { ...part.source, data: "..." } } - } else if (part.type === "tool_result" && typeof part.content !== "string") { - return { - ...part, - content: part.content?.map((contentPart) => { - if (contentPart.type === "image") { - return { ...contentPart, source: { ...contentPart.source, data: "..." } } - } - return contentPart - }), - } - } - return part - }) -} diff --git a/src/api/ollama.ts b/src/api/ollama.ts index d2bd0fe..b1c863e 100644 --- a/src/api/ollama.ts +++ b/src/api/ollama.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." +import { ApiHandler, ApiHandlerMessageResponse } from "." import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api" import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format" @@ -48,23 +48,6 @@ export class OllamaHandler implements ApiHandler { return { message: anthropicMessage } } - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any { - return { - model: this.options.ollamaModelId ?? "", - system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)", - messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }], - tools: "(see tools in src/ClaudeDev.ts)", - tool_choice: "auto", - } - } - getModel(): { id: string; info: ModelInfo } { return { id: this.options.ollamaModelId ?? "", diff --git a/src/api/openai.ts b/src/api/openai.ts index afec4b4..c0b61e4 100644 --- a/src/api/openai.ts +++ b/src/api/openai.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." +import { ApiHandler, ApiHandlerMessageResponse } from "." import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../shared/api" import { convertToAnthropicMessage, convertToOpenAiMessages } from "../utils/openai-format" @@ -48,23 +48,6 @@ export class OpenAiHandler implements ApiHandler { return { message: anthropicMessage } } - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any { - return { - model: this.options.openAiModelId ?? "", - system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)", - messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }], - tools: "(see tools in src/ClaudeDev.ts)", - tool_choice: "auto", - } - } - getModel(): { id: string; info: ModelInfo } { return { id: this.options.openAiModelId ?? "", diff --git a/src/api/openrouter.ts b/src/api/openrouter.ts index 6970540..f4a5efa 100644 --- a/src/api/openrouter.ts +++ b/src/api/openrouter.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." +import { ApiHandler, ApiHandlerMessageResponse } from "." import { ApiHandlerOptions, ModelInfo, @@ -177,24 +177,6 @@ export class OpenRouterHandler implements ApiHandler { return completion } - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any { - return { - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens, - system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)", - messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }], - tools: "(see tools in src/ClaudeDev.ts)", - tool_choice: "auto", - } - } - getModel(): { id: OpenRouterModelId; info: ModelInfo } { const modelId = this.options.apiModelId if (modelId && modelId in openRouterModels) { diff --git a/src/api/vertex.ts b/src/api/vertex.ts index b9199bb..9dfbe32 100644 --- a/src/api/vertex.ts +++ b/src/api/vertex.ts @@ -1,6 +1,6 @@ import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" import { Anthropic } from "@anthropic-ai/sdk" -import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "." +import { ApiHandler, ApiHandlerMessageResponse } from "." import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../shared/api" // https://docs.anthropic.com/en/api/claude-on-vertex-ai @@ -33,24 +33,6 @@ export class VertexHandler implements ApiHandler { return { message } } - createUserReadableRequest( - userContent: Array< - | Anthropic.TextBlockParam - | Anthropic.ImageBlockParam - | Anthropic.ToolUseBlockParam - | Anthropic.ToolResultBlockParam - > - ): any { - return { - model: this.getModel().id, - max_tokens: this.getModel().info.maxTokens, - system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)", - messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }], - tools: "(see tools in src/ClaudeDev.ts)", - tool_choice: { type: "auto" }, - } - } - getModel(): { id: VertexModelId; info: ModelInfo } { const modelId = this.options.apiModelId if (modelId && modelId in vertexModels) { diff --git a/src/integrations/TerminalManager.ts b/src/integrations/TerminalManager.ts index 2aaa53c..88e6154 100644 --- a/src/integrations/TerminalManager.ts +++ b/src/integrations/TerminalManager.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import { EventEmitter } from "events" -import delay from "delay" +import pWaitFor from "p-wait-for" /* TerminalManager: @@ -21,6 +21,14 @@ Enables flexible command execution: - Continue execution in background - Retrieve missed output later +Notes: +- it turns out some shellIntegration APIs are available on cursor, although not on older versions of vscode +- "By default, the shell integration script should automatically activate on supported shells launched from VS Code." +Supported shells: +Linux/macOS: bash, fish, pwsh, zsh +Windows: pwsh + + Example: const terminalManager = new TerminalManager(context); @@ -41,77 +49,75 @@ process.continue(); // Later, if you need to get the unretrieved output: const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId); console.log('Unretrieved output:', unretrievedOutput); + +Resources: +- https://github.com/microsoft/vscode/issues/226655 +- https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api +- https://code.visualstudio.com/docs/terminal/shell-integration +- https://code.visualstudio.com/api/references/vscode-api#Terminal +- https://github.com/microsoft/vscode-extension-samples/blob/main/terminal-sample/src/extension.ts +- https://github.com/microsoft/vscode-extension-samples/blob/main/shell-integration-sample/src/extension.ts */ export class TerminalManager { private terminals: TerminalInfo[] = [] private processes: Map = new Map() - private context: vscode.ExtensionContext private nextTerminalId = 1 - constructor(context: vscode.ExtensionContext) { - this.context = context - this.setupListeners() - } - - private setupListeners() { - // todo: make sure we do this check everywhere we use the new terminal APIs - if (hasShellIntegrationApis()) { - this.context.subscriptions.push( - vscode.window.onDidOpenTerminal(this.handleOpenTerminal.bind(this)), - vscode.window.onDidCloseTerminal(this.handleClosedTerminal.bind(this)), - vscode.window.onDidChangeTerminalShellIntegration(this.handleShellIntegrationChange.bind(this)), - vscode.window.onDidStartTerminalShellExecution(this.handleShellExecutionStart.bind(this)), - vscode.window.onDidEndTerminalShellExecution(this.handleShellExecutionEnd.bind(this)) - ) - } - } - runCommand(terminalInfo: TerminalInfo, command: string, cwd: string): TerminalProcessResultPromise { terminalInfo.busy = true terminalInfo.lastCommand = command - - const process = new TerminalProcess(terminalInfo, command) - + const process = new TerminalProcess() this.processes.set(terminalInfo.id, process) + process.once("completed", () => { + console.log(`completed received for terminal ${terminalInfo.id}`) + terminalInfo.busy = false + }) + const promise = new Promise((resolve, reject) => { - process.once(CONTINUE_EVENT, () => { - console.log("2") + process.once("continue", () => { + console.log(`continue received for terminal ${terminalInfo.id}`) resolve() }) - process.once("error", reject) + process.once("error", (error) => { + console.error(`Error in terminal ${terminalInfo.id}:`, error) + reject(error) + }) }) // if shell integration is already active, run the command immediately if (terminalInfo.terminal.shellIntegration) { + console.log(`Shell integration active for terminal ${terminalInfo.id}, running command immediately`) process.waitForShellIntegration = false - process.run() - } - - if (hasShellIntegrationApis()) { - // Fallback to sendText if there is no shell integration within 3 seconds of launching (could be because the user is not running one of the supported shells) - setTimeout(() => { - if (!terminalInfo.terminal.shellIntegration) { - process.waitForShellIntegration = false - process.run() - // Without shell integration, we can't know when the command has finished or what the - // exit code was. - } - }, 3000) + process.run(terminalInfo.terminal, command) } else { - // User doesn't have shell integration API available, run command the old way - process.waitForShellIntegration = false - process.run() + console.log(`Waiting for shell integration for terminal ${terminalInfo.id}`) + // docs recommend waiting 3s for shell integration to activate + pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => { + console.log( + `Shell integration ${ + terminalInfo.terminal.shellIntegration ? "activated" : "not activated" + } for terminal ${terminalInfo.id}` + ) + + const existingProcess = this.processes.get(terminalInfo.id) + if (existingProcess && existingProcess.waitForShellIntegration) { + existingProcess.waitForShellIntegration = false + existingProcess.run(terminalInfo.terminal, command) + } + }) } - // Merge the process and promise return mergePromise(process, promise) } async getOrCreateTerminal(cwd: string): Promise { const availableTerminal = this.terminals.find((t) => { - if (t.busy) { + // it seems even if you close the terminal, it can still be reused + const isDisposed = !t.terminal || t.terminal.exitStatus // The exit status of the terminal will be undefined while the terminal is active. + console.log(`Terminal ${t.id} isDisposed:`, isDisposed) + if (t.busy || isDisposed) { return false } const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal @@ -121,7 +127,7 @@ export class TerminalManager { return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath }) if (availableTerminal) { - console.log("reusing terminal", availableTerminal.id) + console.log("Reusing terminal", availableTerminal.id) return availableTerminal } @@ -140,63 +146,10 @@ export class TerminalManager { return newTerminalInfo } - private handleOpenTerminal(terminal: vscode.Terminal) { - console.log(`Terminal opened: ${terminal.name}`) - } - - private handleClosedTerminal(terminal: vscode.Terminal) { - const index = this.terminals.findIndex((t) => t.terminal === terminal) - if (index !== -1) { - const terminalInfo = this.terminals[index] - this.terminals.splice(index, 1) - this.processes.delete(terminalInfo.id) - } - console.log(`Terminal closed: ${terminal.name}`) - } - - private handleShellIntegrationChange(e: vscode.TerminalShellIntegrationChangeEvent) { - const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal) - if (terminalInfo) { - const process = this.processes.get(terminalInfo.id) - if (process && process.waitForShellIntegration) { - process.waitForShellIntegration = false - process.run() - } - console.log(`Shell integration activated for terminal: ${e.terminal.name}`) - } - } - - private handleShellExecutionStart(e: vscode.TerminalShellExecutionStartEvent) { - const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal) - if (terminalInfo) { - terminalInfo.busy = true - terminalInfo.lastCommand = e.execution.commandLine.value - console.log(`Command started in terminal ${terminalInfo.id}: ${terminalInfo.lastCommand}`) - } - } - - private handleShellExecutionEnd(e: vscode.TerminalShellExecutionEndEvent) { - const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal) - if (terminalInfo) { - this.handleCommandCompletion(terminalInfo, e.exitCode) - } - } - - private handleCommandCompletion(terminalInfo: TerminalInfo, exitCode?: number | undefined) { - terminalInfo.busy = false - console.log( - `Command "${terminalInfo.lastCommand}" in terminal ${terminalInfo.id} completed with exit code: ${exitCode}` - ) - } - getBusyTerminals(): { id: number; lastCommand: string }[] { return this.terminals.filter((t) => t.busy).map((t) => ({ id: t.id, lastCommand: t.lastCommand })) } - hasBusyTerminals(): boolean { - return this.terminals.some((t) => t.busy) - } - getUnretrievedOutput(terminalId: number): string { const process = this.processes.get(terminalId) if (!process) { @@ -206,19 +159,14 @@ export class TerminalManager { } disposeAll() { - for (const info of this.terminals) { - info.terminal.dispose() // todo do we want to do this? test with tab view closing it - } + // for (const info of this.terminals) { + // //info.terminal.dispose() // dont want to dispose terminals when task is aborted + // } this.terminals = [] this.processes.clear() } } -function hasShellIntegrationApis(): boolean { - const [major, minor] = vscode.version.split(".").map(Number) - return major > 1 || (major === 1 && minor >= 93) -} - interface TerminalInfo { terminal: vscode.Terminal busy: boolean @@ -226,54 +174,59 @@ interface TerminalInfo { id: number } -const CONTINUE_EVENT = "CONTINUE_EVENT" +interface TerminalProcessEvents { + line: [line: string] + continue: [] + completed: [] + error: [error: Error] +} -export class TerminalProcess extends EventEmitter { +export class TerminalProcess extends EventEmitter { waitForShellIntegration: boolean = true private isListening: boolean = true private buffer: string = "" - private execution?: vscode.TerminalShellExecution - private stream?: AsyncIterable private fullOutput: string = "" private lastRetrievedIndex: number = 0 - constructor(public terminalInfo: TerminalInfo, private command: string) { - super() - } + // constructor() { + // super() - async run() { - if (this.terminalInfo.terminal.shellIntegration) { - this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command) - this.stream = this.execution.read() + async run(terminal: vscode.Terminal, command: string) { + if (terminal.shellIntegration) { + console.log(`Shell integration available for terminal`) + const execution = terminal.shellIntegration.executeCommand(command) + const stream = execution.read() // todo: need to handle errors - let isFirstChunk = true // ignore first chunk since it's vscode shell integration marker - for await (const data of this.stream) { - console.log("data", data) - if (!isFirstChunk) { - this.fullOutput += data - if (this.isListening) { - this.emitIfEol(data) - this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length - } - } else { - isFirstChunk = false + for await (const data of stream) { + console.log(`Received data chunk for terminal:`, data) + this.fullOutput += data + if (this.isListening) { + console.log(`Emitting data for terminal`) + this.emitIfEol(data) + this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length } } // Emit any remaining content in the buffer if (this.buffer && this.isListening) { + console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim()) this.emit("line", this.buffer.trim()) this.buffer = "" this.lastRetrievedIndex = this.fullOutput.length } - this.emit(CONTINUE_EVENT) + console.log(`Command execution completed for terminal`) + this.emit("continue") + this.emit("completed") } else { - this.terminalInfo.terminal.sendText(this.command, true) + console.log(`Shell integration not available for terminal, falling back to sendText`) + terminal.sendText(command, true) // For terminals without shell integration, we can't know when the command completes // So we'll just emit the continue event after a delay setTimeout(() => { - this.emit(CONTINUE_EVENT) + console.log(`Emitting continue after delay for terminal`) + this.emit("continue") + // can't emit completed since we don't if the command actually completed, it could still be running server }, 2000) // Adjust this delay as needed } } @@ -294,13 +247,17 @@ export class TerminalProcess extends EventEmitter { } continue() { + // Emit any remaining content in the buffer + if (this.buffer && this.isListening) { + console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim()) + this.emit("line", this.buffer.trim()) + this.buffer = "" + this.lastRetrievedIndex = this.fullOutput.length + } + this.isListening = false this.removeAllListeners("line") - this.emit(CONTINUE_EVENT) - } - - isStillListening() { - return this.isListening + this.emit("continue") } getUnretrievedOutput(): string { diff --git a/src/parse-source-code/index.ts b/src/parse-source-code/index.ts index 5cfe466..c52edb2 100644 --- a/src/parse-source-code/index.ts +++ b/src/parse-source-code/index.ts @@ -4,7 +4,7 @@ import os from "os" import * as path from "path" import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser" -export const LIST_FILES_LIMIT = 500 +export const LIST_FILES_LIMIT = 200 // TODO: implement caching behavior to avoid having to keep analyzing project for new tasks. export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise { diff --git a/src/utils/export-markdown.ts b/src/utils/export-markdown.ts index 7a33905..b82d206 100644 --- a/src/utils/export-markdown.ts +++ b/src/utils/export-markdown.ts @@ -42,7 +42,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi } } -function formatContentBlockToMarkdown( +export function formatContentBlockToMarkdown( block: | Anthropic.TextBlockParam | Anthropic.ImageBlockParam diff --git a/webview-ui/src/components/ChatRow.tsx b/webview-ui/src/components/ChatRow.tsx index 0721e22..e882ef2 100644 --- a/webview-ui/src/components/ChatRow.tsx +++ b/webview-ui/src/components/ChatRow.tsx @@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown" import { ClaudeMessage, ClaudeSayTool } from "../../../src/shared/ExtensionMessage" import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences" import CodeAccordian from "./CodeAccordian" -import CodeBlock from "./CodeBlock" +import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock" import Thumbnails from "./Thumbnails" interface ChatRowProps { @@ -352,8 +352,8 @@ const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessa {isExpanded && (
@@ -516,19 +516,32 @@ const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessa borderRadius: 3, border: "1px solid var(--vscode-sideBar-border)", overflow: "hidden", + backgroundColor: CODE_BLOCK_BG_COLOR, }}> - + + {output.length > 0 && ( +
+
+ + Command Output +
+ {isExpanded && } +
+ )}
- {output.length > 0 && ( -
- -
- )} ) case "completion_result": diff --git a/webview-ui/src/components/CodeBlock.tsx b/webview-ui/src/components/CodeBlock.tsx index 46c685a..6725fb2 100644 --- a/webview-ui/src/components/CodeBlock.tsx +++ b/webview-ui/src/components/CodeBlock.tsx @@ -5,7 +5,7 @@ import styled from "styled-components" import { visit } from "unist-util-visit" import { useExtensionState } from "../context/ExtensionStateContext" -const BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))" +export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))" /* overflowX: auto + inner div with padding results in an issue where the top/left/bottom padding renders but the right padding inside does not count as overflow as the width of the element is not exceeded. Once the inner div is outside the boundaries of the parent it counts as overflow. @@ -15,12 +15,27 @@ this fixes the issue of right padding clipped off minWidth: "max-content", */ -const StyledMarkdown = styled.div` +interface CodeBlockProps { + source?: string + forceWrap?: boolean +} + +const StyledMarkdown = styled.div<{ forceWrap: boolean }>` + ${({ forceWrap }) => + forceWrap && + ` + pre, code { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + } + `} + pre { - background-color: ${BG_COLOR}; + background-color: ${CODE_BLOCK_BG_COLOR}; border-radius: 5px; margin: 0; - min-width: max-content; + min-width: ${({ forceWrap }) => (forceWrap ? "auto" : "max-content")}; padding: 10px 10px; } @@ -43,7 +58,7 @@ const StyledMarkdown = styled.div` } word-wrap: break-word; border-radius: 5px; - background-color: ${BG_COLOR}; + background-color: ${CODE_BLOCK_BG_COLOR}; font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); font-family: var(--vscode-editor-font-family); } @@ -53,7 +68,7 @@ const StyledMarkdown = styled.div` color: #f78383; } - background-color: ${BG_COLOR}; + background-color: ${CODE_BLOCK_BG_COLOR}; font-family: var(--vscode-font-family), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); @@ -84,7 +99,7 @@ const StyledPre = styled.pre<{ theme: any }>` .join("")} ` -const CodeBlock = memo(({ source }: { source?: string }) => { +const CodeBlock = memo(({ source, forceWrap = false }: CodeBlockProps) => { const { theme } = useExtensionState() const [reactContent, setMarkdownSource] = useRemark({ remarkPlugins: [ @@ -121,11 +136,11 @@ const CodeBlock = memo(({ source }: { source?: string }) => { return (
- {reactContent} + {reactContent}
) }) diff --git a/webview-ui/src/components/TaskHeader.tsx b/webview-ui/src/components/TaskHeader.tsx index b2ff32c..31e4c39 100644 --- a/webview-ui/src/components/TaskHeader.tsx +++ b/webview-ui/src/components/TaskHeader.tsx @@ -109,7 +109,7 @@ const TaskHeader: React.FC = ({ justifyContent: "space-between", alignItems: "center", }}> - Task + Task