import React, { useState, useEffect, useRef, useMemo } 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: React.FC = ({ rawOutput, handleSendStdin, shouldAllowInput }) => { const [userInput, setUserInput] = useState("") const [isFocused, setIsFocused] = useState(false) // Initially not focused const textAreaRef = useRef(null) const mirrorRef = useRef(null) const hiddenTextareaRef = useRef(null) const [lastProcessedOutput, setLastProcessedOutput] = useState("") const output = useMemo(() => { return stripAnsi(rawOutput) }, [rawOutput]) useEffect(() => { if (lastProcessedOutput !== output) { setUserInput("") } }, [output, lastProcessedOutput]) const handleKeyPress = (e: React.KeyboardEvent) => { 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 = " " 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 = " " mirror.appendChild(caretEle) } }, [output, userInput, isFocused, shouldAllowInput]) const handleChange = (e: React.ChangeEvent) => { 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) => { 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 = " " 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 (
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} />
) } export default Terminal