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

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)