Add new vscode shell integration to run commands right in terminal

This commit is contained in:
Saoud Rizwan
2024-09-08 10:30:50 -04:00
parent e6d95eaad4
commit 2c91bafe1e
10 changed files with 410 additions and 663 deletions

193
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "1.5.33",
"version": "1.5.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "1.5.33",
"version": "1.5.34",
"license": "MIT",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
@@ -20,7 +20,6 @@
"default-shell": "^2.2.0",
"delay": "^6.0.0",
"diff": "^5.2.0",
"execa": "^9.3.0",
"globby": "^14.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
@@ -29,7 +28,6 @@
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"serialize-error": "^11.0.3",
"tree-kill": "^1.2.2",
"tree-sitter-wasms": "^0.1.11",
"web-tree-sitter": "^0.22.6"
},
@@ -37,7 +35,7 @@
"@types/diff": "^5.2.1",
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/vscode": "^1.82.0",
"@types/vscode": "^1.93.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
"@vscode/test-cli": "^0.0.9",
@@ -2829,24 +2827,6 @@
"node": ">=14"
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@smithy/abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.1.tgz",
@@ -4547,9 +4527,9 @@
"license": "MIT"
},
"node_modules/@types/vscode": {
"version": "1.91.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.91.0.tgz",
"integrity": "sha512-PgPr+bUODjG3y+ozWUCyzttqR9EHny9sPAfJagddQjDwdtf66y2sDKJMnFZRuzBA2YtBGASqJGPil8VDUPvO6A==",
"version": "1.93.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.93.0.tgz",
"integrity": "sha512-kUK6jAHSR5zY8ps42xuW89NLcBpw1kOabah7yv38J8MyiYuOHxLQBi0e7zeXbQgVefDy/mZZetqEFC+Fl5eIEQ==",
"dev": true,
"license": "MIT"
},
@@ -6144,44 +6124,6 @@
"node": ">=6"
}
},
"node_modules/execa": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.3.0.tgz",
"integrity": "sha512-l6JFbqnHEadBoVAVpN5dl2yCyfX28WoBAGaoQcNmLLSedOxTxcn2Qa83s8I/PA5i56vWru2OHOtrwF7Om2vqlg==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.3",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^7.0.0",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^5.2.0",
"pretty-ms": "^9.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -6254,33 +6196,6 @@
"reusify": "^1.0.4"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
"integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/figures/node_modules/is-unicode-supported": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz",
"integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6586,22 +6501,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-symbol-description": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz",
@@ -6912,15 +6811,6 @@
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-7.0.0.tgz",
"integrity": "sha512-74kytxOUSvNbjrT9KisAbaTZ/eJwD/LrbM/kh5j0IhPuJzwuA19dWvniFGwBzN9rVjg+O/e+F310PjObDXS+9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
@@ -7300,18 +7190,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-string": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
@@ -8715,18 +8593,6 @@
"node": ">=4"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8867,21 +8733,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-ms": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz",
"integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==",
"license": "MIT",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -9635,18 +9486,6 @@
"node": ">=4"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -9787,14 +9626,6 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tree-sitter-wasms": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.11.tgz",
@@ -10443,18 +10274,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz",
"integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -122,7 +122,7 @@
"@types/diff": "^5.2.1",
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/vscode": "^1.82.0",
"@types/vscode": "^1.93.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
"@vscode/test-cli": "^0.0.9",
@@ -144,7 +144,6 @@
"default-shell": "^2.2.0",
"delay": "^6.0.0",
"diff": "^5.2.0",
"execa": "^9.3.0",
"globby": "^14.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
@@ -153,7 +152,6 @@
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"serialize-error": "^11.0.3",
"tree-kill": "^1.2.2",
"tree-sitter-wasms": "^0.1.11",
"web-tree-sitter": "^0.22.6"
}

View File

@@ -2,14 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import defaultShell from "default-shell"
import delay from "delay"
import * as diff from "diff"
import { execa, ExecaError, ResultPromise } from "execa"
import fs from "fs/promises"
import os from "os"
import osName from "os-name"
import pWaitFor from "p-wait-for"
import * as path from "path"
import { serializeError } from "serialize-error"
import treeKill from "tree-kill"
import * as vscode from "vscode"
import { ApiHandler, buildApiHandler } from "./api"
import { LIST_FILES_LIMIT, listFiles, parseSourceCodeForDefinitionsTopLevel } from "./parse-source-code"
@@ -17,7 +15,7 @@ import { ClaudeDevProvider } from "./providers/ClaudeDevProvider"
import { ApiConfiguration } from "./shared/api"
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
import { combineApiRequests } from "./shared/combineApiRequests"
import { combineCommandSequences, COMMAND_STDIN_STRING } from "./shared/combineCommandSequences"
import { combineCommandSequences } from "./shared/combineCommandSequences"
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "./shared/ExtensionMessage"
import { getApiMetrics } from "./shared/getApiMetrics"
import { HistoryItem } from "./shared/HistoryItem"
@@ -28,6 +26,7 @@ import { truncateHalfConversation } from "./utils/context-management"
import { regexSearchFiles } from "./utils/ripgrep"
import { extractTextFromFile } from "./utils/extract-text"
import { getPythonEnvPath } from "./utils/get-python-env"
import { TerminalManager } from "./integrations/TerminalManager"
const SYSTEM_PROMPT =
async () => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
@@ -257,6 +256,7 @@ type UserContent = Array<
export class ClaudeDev {
readonly taskId: string
private api: ApiHandler
private terminalManager: TerminalManager
private customInstructions?: string
private alwaysAllowReadOnly: boolean
apiConversationHistory: Anthropic.MessageParam[] = []
@@ -265,7 +265,6 @@ export class ClaudeDev {
private askResponseText?: string
private askResponseImages?: string[]
private lastMessageTs?: number
private executeCommandRunningProcess?: ResultPromise
private consecutiveMistakeCount: number = 0
private shouldSkipNextApiReqStartedMessage = false
private providerRef: WeakRef<ClaudeDevProvider>
@@ -282,6 +281,7 @@ export class ClaudeDev {
) {
this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager(provider.context)
this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -731,10 +731,7 @@ export class ClaudeDev {
abortTask() {
this.abort = true // will stop any autonomously running promises
const runningProcessId = this.executeCommandRunningProcess?.pid
if (runningProcessId) {
treeKill(runningProcessId, "SIGTERM")
}
this.terminalManager.disposeAll()
}
async executeTool(toolName: ToolName, toolInput: any): Promise<ToolResponse> {
@@ -1420,92 +1417,41 @@ export class ClaudeDev {
return "The user denied this operation."
}
let userFeedback: { text?: string; images?: string[] } | undefined
const sendCommandOutput = async (subprocess: ResultPromise, line: string): Promise<void> => {
try {
const { response, text, images } = await this.ask("command_output", line)
const isStdin = (text ?? "").startsWith(COMMAND_STDIN_STRING)
// if this ask promise is not ignored, that means the user responded to it somehow either by clicking primary button or by typing text
if (response === "yesButtonTapped") {
// SIGINT is typically what's sent when a user interrupts a process (like pressing Ctrl+C)
/*
.kill sends SIGINT by default. However by not passing any options into .kill(), execa internally sends a SIGKILL after a grace period if the SIGINT failed.
however it turns out that even this isn't enough for certain processes like npm starting servers. therefore we use the tree-kill package to kill all processes in the process tree, including the root process.
- Sends signal to all children processes of the process with pid pid, including pid. Signal defaults to SIGTERM.
*/
if (subprocess.pid) {
//subprocess.kill("SIGINT") // will result in for loop throwing error
treeKill(subprocess.pid, "SIGINT")
}
} else {
if (isStdin) {
const stdin = text?.slice(COMMAND_STDIN_STRING.length) ?? ""
try {
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
const process = this.terminalManager.runCommand(terminalInfo, command, cwd)
// replace last commandoutput with + stdin
const lastCommandOutput = findLastIndex(this.claudeMessages, (m) => m.ask === "command_output")
if (lastCommandOutput !== -1) {
this.claudeMessages[lastCommandOutput].text += stdin
}
// if the user sent some input, we send it to the command stdin
// add newline as cli programs expect a newline after each input
// (stdin needs to be set to `pipe` to send input to the command, execa does this by default when using template literals - other options are inherit (from parent process stdin) or null (no stdin))
subprocess.stdin?.write(stdin + "\n")
// Recurse with an empty string to continue listening for more input
sendCommandOutput(subprocess, "") // empty strings are effectively ignored by the webview, this is done solely to relinquish control over the exit command button
let userFeedback: { text?: string; images?: string[] } | undefined
const sendCommandOutput = async (line: string): Promise<void> => {
try {
const { response, text, images } = await this.ask("command_output", line)
if (response === "yesButtonTapped") {
// proceed while running
} else {
userFeedback = { text, images }
if (subprocess.pid) {
treeKill(subprocess.pid, "SIGINT")
}
}
process.continue() // continue past the await
} catch {
// This can only happen if this ask promise was ignored, so ignore this error
}
} catch {
// This can only happen if this ask promise was ignored, so ignore this error
}
}
try {
let result = ""
// execa by default tries to convert bash into javascript, so need to specify `shell: true` to use sh on unix or cmd.exe on windows
// also worth noting that execa`input` and the execa(command) have nuanced differences like the template literal version handles escaping for you, while with the function call, you need to be more careful about how arguments are passed, especially when using shell: true.
// execa returns a promise-like object that is both a promise and a Subprocess that has properties like stdin
const subprocess = execa({ shell: true, cwd: cwd })`${command}`
this.executeCommandRunningProcess = subprocess
subprocess.stdout?.on("data", (data) => {
if (data) {
const output = data.toString()
// stream output to user in realtime
// do not await since it's sent as an ask and we are not waiting for a response
sendCommandOutput(subprocess, output)
result += output
}
process.on("line", (line) => {
console.log("sending line from here", line)
result += line
sendCommandOutput(line)
})
try {
await subprocess
// NOTE: using for await to stream execa output does not return lines that expect user input, so we use listen to the stdout stream and handle data directly, allowing us to process output as soon as it's available even before a full line is complete.
// for await (const chunk of subprocess) {
// const line = chunk.toString()
// sendCommandOutput(subprocess, line)
// result += `${line}\n`
// }
} catch (e) {
if ((e as ExecaError).signal === "SIGINT") {
//await this.say("command_output", `\nUser exited command...`)
result += `\n====\nUser terminated command process via SIGINT. This is not an error. Please continue with your task, but keep in mind that the command is no longer running. For example, if this command was used to start a server for a react app, the server is no longer running and you cannot open a browser to view it anymore.`
} else {
throw e // if the command was not terminated by user, let outer catch handle it as a real error
}
}
await process
// Wait for a short delay to ensure all messages are sent to the webview
// This delay allows time for non-awaited promises to be created and
// for their associated messages to be sent to the webview, maintaining
// the correct order of messages (although the webview is smart about
// grouping command_output messages despite any gaps anyways)
await delay(100)
this.executeCommandRunningProcess = undefined
await delay(10)
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images)
@@ -1522,12 +1468,10 @@ export class ClaudeDev {
return ""
}
return `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`
} catch (e) {
const error = e as any
} 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}`) // TODO: in webview show code block for command errors
this.executeCommandRunningProcess = undefined
await this.say("error", `Error executing command:\n${errorMessage}`)
return errorString
}
}

View File

@@ -0,0 +1,328 @@
import * as vscode from "vscode"
import { EventEmitter } from "events"
import delay from "delay"
/*
TerminalManager:
- Creates/reuses terminals
- Runs commands via runCommand(), returning a TerminalProcess
- Handles shell integration events
TerminalProcess extends EventEmitter and implements Promise:
- Emits 'line' events with output while promise is pending
- process.continue() resolves promise and stops event emission
- Allows real-time output handling or background execution
getUnretrievedOutput() fetches latest output for ongoing commands
Enables flexible command execution:
- Await for completion
- Listen to real-time events
- Continue execution in background
- Retrieve missed output later
Example:
const terminalManager = new TerminalManager(context);
// Run a command
const process = terminalManager.runCommand('npm install', '/path/to/project');
process.on('line', (line) => {
console.log(line);
});
// To wait for the process to complete naturally:
await process;
// Or to continue execution even if the command is still running:
process.continue();
// Later, if you need to get the unretrieved output:
const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId);
console.log('Unretrieved output:', unretrievedOutput);
*/
export class TerminalManager {
private static readonly TERMINAL_NAME = "Claude Dev"
private terminals: TerminalInfo[] = []
private processes: Map<number, TerminalProcess> = 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)
this.processes.set(terminalInfo.id, process)
const promise = new Promise<void>((resolve, reject) => {
process.once(CONTINUE_EVENT, () => {
console.log("2")
resolve()
})
process.once("error", reject)
})
// if shell integration is already active, run the command immediately
if (terminalInfo.terminal.shellIntegration) {
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)
} else {
// User doesn't have shell integration API available, run command the old way
process.waitForShellIntegration = false
process.run()
}
// Merge the process and promise
return mergePromise(process, promise)
}
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
const availableTerminal = this.terminals.find((t) => {
if (t.busy) {
return false
}
const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal
if (!terminalCwd) {
return false
}
return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath
})
if (availableTerminal) {
console.log("reusing terminal", availableTerminal.id)
return availableTerminal
}
const newTerminal = vscode.window.createTerminal({
name: `${TerminalManager.TERMINAL_NAME} ${this.nextTerminalId}`,
cwd: cwd,
})
const newTerminalInfo: TerminalInfo = {
terminal: newTerminal,
busy: false,
lastCommand: "",
id: this.nextTerminalId++,
}
this.terminals.push(newTerminalInfo)
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) {
return ""
}
return process.getUnretrievedOutput()
}
disposeAll() {
for (const info of this.terminals) {
info.terminal.dispose() // todo do we want to do this? test with tab view closing it
}
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
lastCommand: string
id: number
}
const CONTINUE_EVENT = "CONTINUE_EVENT"
export class TerminalProcess extends EventEmitter {
waitForShellIntegration: boolean = true
private isListening: boolean = true
private buffer: string = ""
private execution?: vscode.TerminalShellExecution
private stream?: AsyncIterable<string>
private fullOutput: string = ""
private lastRetrievedIndex: number = 0
constructor(public terminalInfo: TerminalInfo, private command: string) {
super()
}
async run() {
if (this.terminalInfo.terminal.shellIntegration) {
this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command)
this.stream = this.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
}
}
// Emit any remaining content in the buffer
if (this.buffer && this.isListening) {
this.emit("line", this.buffer.trim())
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
this.emit(CONTINUE_EVENT)
} else {
this.terminalInfo.terminal.sendText(this.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)
}, 2000) // Adjust this delay as needed
}
}
// Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
private emitIfEol(chunk: string) {
this.buffer += chunk
let lineEndIndex: number
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
let line = this.buffer.slice(0, lineEndIndex).trim()
// Remove \r if present (for Windows-style line endings)
// if (line.endsWith("\r")) {
// line = line.slice(0, -1)
// }
this.emit("line", line)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
}
continue() {
this.isListening = false
this.removeAllListeners("line")
this.emit(CONTINUE_EVENT)
}
isStillListening() {
return this.isListening
}
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return unretrieved
}
}
export type TerminalProcessResultPromise = TerminalProcess & Promise<void>
// Similar to execa's ResultPromise, this lets us create a mixin of both a TerminalProcess and a Promise: https://github.com/sindresorhus/execa/blob/main/lib/methods/promise.js
function mergePromise(process: TerminalProcess, promise: Promise<void>): TerminalProcessResultPromise {
const nativePromisePrototype = (async () => {})().constructor.prototype
const descriptors = ["then", "catch", "finally"].map(
(property) => [property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property)] as const
)
for (const [property, descriptor] of descriptors) {
if (descriptor) {
const value = descriptor.value.bind(promise)
Reflect.defineProperty(process, property, { ...descriptor, value })
}
}
return process as TerminalProcessResultPromise
}

View File

@@ -71,4 +71,3 @@ export function combineCommandSequences(messages: ClaudeMessage[]): ClaudeMessag
})
}
export const COMMAND_OUTPUT_STRING = "Output:"
export const COMMAND_STDIN_STRING = "Input:"

View File

@@ -27,7 +27,6 @@
"react-virtuoso": "^4.7.13",
"rehype-highlight": "^7.0.0",
"rewire": "^7.0.0",
"strip-ansi": "^7.1.0",
"styled-components": "^6.1.13",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

View File

@@ -22,7 +22,6 @@
"react-virtuoso": "^4.7.13",
"rehype-highlight": "^7.0.0",
"rewire": "^7.0.0",
"strip-ansi": "^7.1.0",
"styled-components": "^6.1.13",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

View File

@@ -1,13 +1,12 @@
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import React, { memo, useMemo } from "react"
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 Terminal from "./Terminal"
import Thumbnails from "./Thumbnails"
import deepEqual from "fast-deep-equal"
interface ChatRowProps {
message: ClaudeMessage
@@ -15,7 +14,6 @@ interface ChatRowProps {
onToggleExpand: () => void
lastModifiedMessage?: ClaudeMessage
isLast: boolean
handleSendStdin: (text: string) => void
}
const ChatRow = memo(
@@ -36,14 +34,7 @@ const ChatRow = memo(
export default ChatRow
const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isLast,
handleSendStdin,
}: ChatRowProps) => {
const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowProps) => {
const cost = useMemo(() => {
if (message.text != null && message.say === "api_req_started") {
return JSON.parse(message.text).cost
@@ -483,7 +474,29 @@ const ChatRowContent = ({
}
return {
command: text.slice(0, outputIndex).trim(),
output: text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim() + " ",
output: text
.slice(outputIndex + COMMAND_OUTPUT_STRING.length)
.trim()
.split("")
.map((char) => {
switch (char) {
case "\n":
return "↵\n"
case "\r":
return "⏎"
case "\t":
return "→ "
case "\b":
return "⌫"
case "\f":
return "⏏"
case "\v":
return "⇳"
default:
return char
}
})
.join(""),
}
}
@@ -494,11 +507,28 @@ const ChatRowContent = ({
{icon}
{title}
</div>
<Terminal
{/* <Terminal
rawOutput={command + (output ? "\n" + output : "")}
handleSendStdin={handleSendStdin}
shouldAllowInput={!!isCommandExecuting && output.length > 0}
/>
/> */}
<div
style={{
borderRadius: 3,
border: "1px solid var(--vscode-sideBar-border)",
overflow: "hidden",
}}>
<CodeBlock source={`${"```"}shell\n${command}\n${"```"}`} />
</div>
{output.length > 0 && (
<div
style={{
borderRadius: 3,
border: "1px solid var(--vscode-sideBar-border)",
overflow: "hidden",
}}>
<CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />
</div>
)}
</>
)
case "completion_result":

View File

@@ -5,7 +5,7 @@ import { useEvent, useMount } from "react-use"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
import { combineApiRequests } from "../../../src/shared/combineApiRequests"
import { combineCommandSequences, COMMAND_STDIN_STRING } from "../../../src/shared/combineCommandSequences"
import { combineCommandSequences } from "../../../src/shared/combineCommandSequences"
import { getApiMetrics } from "../../../src/shared/getApiMetrics"
import { useExtensionState } from "../context/ExtensionStateContext"
import { vscode } from "../utils/vscode"
@@ -118,7 +118,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setTextAreaDisabled(false)
setClaudeAsk("command_output")
setEnableButtons(true)
setPrimaryButtonText("Exit Command")
setPrimaryButtonText("Proceed While Running")
setSecondaryButtonText(undefined)
break
case "completion_result":
@@ -224,23 +224,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
}, [inputValue, selectedImages, messages.length, claudeAsk])
const handleSendStdin = useCallback(
(text: string) => {
if (claudeAsk === "command_output") {
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
text: COMMAND_STDIN_STRING + text,
})
setClaudeAsk(undefined)
// don't need to disable since extension relinquishes control back immediately
// setTextAreaDisabled(true)
// setEnableButtons(false)
}
},
[claudeAsk]
)
const startNewTask = useCallback(() => {
vscode.postMessage({ type: "clearTask" })
}, [])
@@ -468,10 +451,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
onToggleExpand={() => toggleRowExpansion(message.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === visibleMessages.length - 1}
handleSendStdin={handleSendStdin}
/>
),
[expandedRows, modifiedMessages, visibleMessages.length, handleSendStdin]
[expandedRows, modifiedMessages, visibleMessages.length]
)
return (

View File

@@ -1,351 +0,0 @@
import React, { useState, useEffect, useRef, useMemo, memo } from "react"
import DynamicTextArea from "react-textarea-autosize"
import stripAnsi from "strip-ansi"
interface TerminalProps {
rawOutput: string
handleSendStdin: (text: string) => void
shouldAllowInput: boolean
}
/*
Inspired by https://phuoc.ng/collection/mirror-a-text-area/create-your-own-custom-cursor-in-a-text-area/
Note: Even though vscode exposes var(--vscode-terminalCursor-foreground) it does not render in front of a color that isn't var(--vscode-terminal-background), and it turns out a lot of themes don't even define some/any of these terminal color variables. Very odd behavior, so try changing themes/color variables if you don't see the caret.
*/
const Terminal = ({ rawOutput, handleSendStdin, shouldAllowInput }: TerminalProps) => {
const [userInput, setUserInput] = useState("")
const [isFocused, setIsFocused] = useState(false) // Initially not focused
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const mirrorRef = useRef<HTMLDivElement>(null)
const hiddenTextareaRef = useRef<HTMLTextAreaElement>(null)
const [lastProcessedOutput, setLastProcessedOutput] = useState("")
const output = useMemo(() => {
return stripAnsi(rawOutput)
}, [rawOutput])
useEffect(() => {
if (lastProcessedOutput !== output) {
setUserInput("")
}
}, [output, lastProcessedOutput])
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter") {
e.preventDefault()
handleSendStdin(userInput)
// setUserInput("") // Clear user input after processing
setLastProcessedOutput(output)
// Trigger resize after clearing input
const textarea = textAreaRef.current
const hiddenTextarea = hiddenTextareaRef.current
if (textarea && hiddenTextarea) {
hiddenTextarea.value = ""
const newHeight = hiddenTextarea.scrollHeight
textarea.style.height = `${newHeight}px`
}
}
}
useEffect(() => {
setUserInput("") // Reset user input when output changes
}, [output])
useEffect(() => {
const textarea = textAreaRef.current
const mirror = mirrorRef.current
const hiddenTextarea = hiddenTextareaRef.current
if (!textarea || !mirror || !hiddenTextarea) return
const textareaStyles = window.getComputedStyle(textarea)
const stylesToCopy = [
"border",
"boxSizing",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"padding",
"textDecoration",
"textIndent",
"textTransform",
"whiteSpace",
"wordSpacing",
"wordWrap",
"width",
"height",
]
stylesToCopy.forEach((property) => {
mirror.style[property as any] = textareaStyles[property as any]
hiddenTextarea.style[property as any] = textareaStyles[property as any]
})
mirror.style.borderColor = "transparent"
hiddenTextarea.style.visibility = "hidden"
hiddenTextarea.style.position = "absolute"
// hiddenTextarea.style.height = "auto"
hiddenTextarea.style.width = `${textarea.clientWidth}px`
hiddenTextarea.style.whiteSpace = "pre-wrap"
hiddenTextarea.style.overflowWrap = "break-word"
// const borderWidth = parseInt(textareaStyles.borderWidth, 10) || 0
const updateSize = () => {
hiddenTextarea.value = textarea.value
const newHeight = hiddenTextarea.scrollHeight
textarea.style.height = `${newHeight}px`
mirror.style.width = `${textarea.offsetWidth}px`
mirror.style.height = `${newHeight}px`
hiddenTextarea.style.width = `${textarea.offsetWidth}px`
hiddenTextarea.style.height = `${newHeight}px`
}
updateSize()
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(textarea)
// Add window resize event listener
const handleWindowResize = () => {
hiddenTextarea.style.width = `${textarea.clientWidth}px`
updateSize()
}
window.addEventListener("resize", handleWindowResize)
return () => {
resizeObserver.disconnect()
window.removeEventListener("resize", handleWindowResize)
}
}, [])
useEffect(() => {
const textarea = textAreaRef.current
const mirror = mirrorRef.current
if (!textarea || !mirror) return
const handleScroll = () => {
if (mirror) mirror.scrollTop = textarea.scrollTop
}
textarea.addEventListener("scroll", handleScroll)
return () => textarea.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
const textarea = textAreaRef.current
const mirror = mirrorRef.current
if (!textarea || !mirror) return
const updateMirror = () => {
const cursorPos = textarea.selectionStart
const textBeforeCursor = textarea.value.substring(0, cursorPos)
const textAfterCursor = textarea.value.substring(cursorPos)
mirror.innerHTML = ""
mirror.appendChild(document.createTextNode(textBeforeCursor))
const caretEle = document.createElement("span")
caretEle.classList.add("terminal-cursor")
if (isFocused) {
caretEle.classList.add("terminal-cursor-focused")
}
if (!shouldAllowInput) {
caretEle.classList.add("terminal-cursor-hidden")
}
caretEle.innerHTML = "&nbsp;"
mirror.appendChild(caretEle)
mirror.appendChild(document.createTextNode(textAfterCursor))
}
// Update mirror on initial render
updateMirror()
document.addEventListener("selectionchange", updateMirror)
return () => document.removeEventListener("selectionchange", updateMirror)
}, [userInput, isFocused, shouldAllowInput])
useEffect(() => {
// Position the dummy caret at the end of the text on initial render
const mirror = mirrorRef.current
if (mirror) {
const text = output + userInput
mirror.innerHTML = ""
mirror.appendChild(document.createTextNode(text))
const caretEle = document.createElement("span")
caretEle.classList.add("terminal-cursor")
if (isFocused) {
caretEle.classList.add("terminal-cursor-focused")
}
if (!shouldAllowInput) {
caretEle.classList.add("terminal-cursor-hidden")
}
caretEle.innerHTML = "&nbsp;"
mirror.appendChild(caretEle)
}
}, [output, userInput, isFocused, shouldAllowInput])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
// Ensure the user can only edit their input after the output
if (newValue.startsWith(output)) {
setUserInput(newValue.slice(output.length))
} else {
// If the user tries to edit the output part, reset the value to the correct state
e.target.value = output + userInput
}
// Trigger resize after setting user input
const textarea = textAreaRef.current
const hiddenTextarea = hiddenTextareaRef.current
if (textarea && hiddenTextarea) {
hiddenTextarea.value = output + userInput
const newHeight = hiddenTextarea.scrollHeight
textarea.style.height = `${newHeight}px`
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = e.target as HTMLTextAreaElement
const cursorPosition = textarea.selectionStart
// Prevent backspace from deleting the output part
if (e.key === "Backspace" && cursorPosition <= output.length) {
e.preventDefault()
}
// Update cursor position on backspace
setTimeout(() => {
const cursorPos = textarea.selectionStart
const textBeforeCursor = textarea.value.substring(0, cursorPos)
const textAfterCursor = textarea.value.substring(cursorPos)
mirrorRef.current!.innerHTML = ""
mirrorRef.current!.appendChild(document.createTextNode(textBeforeCursor))
const caretEle = document.createElement("span")
caretEle.classList.add("terminal-cursor")
if (isFocused) {
caretEle.classList.add("terminal-cursor-focused")
}
if (!shouldAllowInput) {
caretEle.classList.add("terminal-cursor-hidden")
}
caretEle.innerHTML = "&nbsp;"
mirrorRef.current!.appendChild(caretEle)
mirrorRef.current!.appendChild(document.createTextNode(textAfterCursor))
}, 0)
}
const textAreaStyle: React.CSSProperties = {
fontFamily: "var(--vscode-editor-font-family)",
fontSize: "var(--vscode-editor-font-size)",
padding: "10px",
border: "1px solid var(--vscode-editorGroup-border)",
outline: "none",
whiteSpace: "pre-wrap",
overflow: "hidden",
width: "100%",
boxSizing: "border-box",
resize: "none",
}
return (
<div className="terminal-container">
<style>
{`
.terminal-container {
position: relative;
overflow: hidden; // Add this
}
.terminal-textarea {
background: transparent;
caret-color: transparent;
position: relative;
z-index: 1;
}
.terminal-mirror {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
color: transparent;
z-index: 0;
}
.terminal-cursor {
border: 1px solid var(--vscode-terminal-foreground, #FFFFFF);
position: absolute;
width: 4px;
margin-top: -0.5px;
}
.terminal-cursor-focused {
background-color: var(--vscode-terminal-foreground, #FFFFFF);
animation: blink 1s step-end infinite;
}
.terminal-cursor-hidden {
display: none;
}
@keyframes blink {
50% {
opacity: 0;
}
}
`}
</style>
<DynamicTextArea
ref={textAreaRef}
value={output + (shouldAllowInput ? userInput : "")}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className="terminal-textarea"
style={{
// backgroundColor: "var(--vscode-editor-background)", // NOTE: adding cursor ontop of this color wouldnt work on some themes
caretColor: "transparent", // Hide default caret
color: "var(--vscode-terminal-foreground)",
borderRadius: "3px",
...(textAreaStyle as any),
}}
minRows={1}
/>
<div ref={mirrorRef} className="terminal-mirror"></div>
<DynamicTextArea
ref={hiddenTextareaRef}
className="terminal-textarea"
aria-hidden="true"
tabIndex={-1}
readOnly
minRows={1}
style={{
position: "absolute",
top: 0,
left: 0,
height: "100%",
width: "100%",
overflow: "hidden",
opacity: 0,
...(textAreaStyle as any),
}}
/>
</div>
)
}
export default memo(Terminal)