abort: boolean = false
@@ -246,9 +246,14 @@ export class ClaudeDev {
}
this.askResponse = undefined
this.askResponseText = undefined
- await this.providerRef.deref()?.addClaudeMessage({ ts: Date.now(), type: "ask", ask: type, text: question })
+ const askTs = Date.now()
+ this.lastMessageTs = askTs
+ await this.providerRef.deref()?.addClaudeMessage({ ts: askTs, type: "ask", ask: type, text: question })
await this.providerRef.deref()?.postStateToWebview()
- await pWaitFor(() => this.askResponse !== undefined, { interval: 100 })
+ await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
+ if (this.lastMessageTs !== askTs) {
+ throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
+ }
const result = { response: this.askResponse!, text: this.askResponseText }
this.askResponse = undefined
this.askResponseText = undefined
@@ -259,7 +264,9 @@ export class ClaudeDev {
if (this.abort) {
throw new Error("ClaudeDev instance aborted")
}
- await this.providerRef.deref()?.addClaudeMessage({ ts: Date.now(), type: "say", say: type, text: text })
+ const sayTs = Date.now()
+ this.lastMessageTs = sayTs
+ await this.providerRef.deref()?.addClaudeMessage({ ts: sayTs, type: "say", say: type, text: text })
await this.providerRef.deref()?.postStateToWebview()
}
@@ -511,11 +518,28 @@ export class ClaudeDev {
}
try {
let result = ""
- // execa by default tries to convery bash into javascript
- // by using shell: true we use sh on unix or cmd.exe on windows
- // also worth noting that execa`input` runs commands and the execa() creates a new instance
- for await (const line of execa({ shell: true })`${command}`) {
- this.say("command_output", line) // stream output to user in realtime
+ // 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 })`${command}`
+
+ for await (const chunk of subprocess) {
+ const line = chunk.toString()
+ // stream output to user in realtime
+ this.ask("command_output", line)
+ .then(({ response, text }) => {
+ // 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") {
+ subprocess.kill() // Will result in for loop throwing error, so claude will know command failed
+ } else {
+ // if the user sent some input, we send it to the command stdin
+ // add newline as cli programs expect a newline after each input
+ subprocess.stdin?.write(text + "\n")
+ }
+ })
+ .catch(() => {
+ // this can only happen if this ask promise was ignored, so ignore this error
+ })
result += `${line}\n`
}
// for attemptCompletion, we don't want to return the command output
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 675874f..4398a7a 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -28,6 +28,7 @@ export type ClaudeAsk =
| "request_limit_reached"
| "followup"
| "command"
+ | "command_output"
| "completion_result"
| "tool"
| "api_req_failed"
@@ -38,7 +39,6 @@ export type ClaudeSay =
| "api_req_started"
| "api_req_finished"
| "text"
- | "command_output"
| "completion_result"
| "user_feedback"
| "api_req_retried"
diff --git a/webview-ui/src/components/ChatView.tsx b/webview-ui/src/components/ChatView.tsx
index 128176d..1df9d64 100644
--- a/webview-ui/src/components/ChatView.tsx
+++ b/webview-ui/src/components/ChatView.tsx
@@ -108,6 +108,13 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
setPrimaryButtonText("Run Command")
setSecondaryButtonText("Reject")
break
+ case "command_output":
+ setTextAreaDisabled(false)
+ setClaudeAsk("command_output")
+ setEnableButtons(true)
+ setPrimaryButtonText("Exit Command Early")
+ setSecondaryButtonText(undefined)
+ break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
setTextAreaDisabled(false)
@@ -131,8 +138,6 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
break
case "text":
break
- case "command_output":
- break
case "completion_result":
break
}
@@ -168,6 +173,7 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
case "followup":
case "tool":
case "command": // user can provide feedback to a tool or command use
+ case "command_output": // user can send input to command stdin
case "completion_result": // if this happens then the user has feedback for the completion result
vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text })
break
@@ -191,6 +197,7 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
case "request_limit_reached":
case "api_req_failed":
case "command":
+ case "command_output":
case "tool":
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
break
@@ -315,6 +322,13 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
return () => clearTimeout(timer)
}, [visibleMessages])
+ const placeholderText = useMemo(() => {
+ if (messages.at(-1)?.ask === "command_output") {
+ return "Type input for command stdin..."
+ }
+ return task ? "Type a message..." : "Type your task here..."
+ }, [task, messages])
+
return (
!(msg.type === "say" && msg.say === "command_output"))
+ .filter((msg) => !(msg.type === "ask" && msg.ask === "command_output"))
.map((msg) => {
if (msg.type === "ask" && msg.ask === "command") {
const combinedCommand = combinedCommands.find((cmd) => cmd.ts === msg.ts)