mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 12:21:13 -05:00
Add terminal emulator for better command interactivity; add python env to system information
This commit is contained in:
@@ -17,7 +17,7 @@ import { ClaudeDevProvider } from "./providers/ClaudeDevProvider"
|
|||||||
import { ApiConfiguration } from "./shared/api"
|
import { ApiConfiguration } from "./shared/api"
|
||||||
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
|
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
|
||||||
import { combineApiRequests } from "./shared/combineApiRequests"
|
import { combineApiRequests } from "./shared/combineApiRequests"
|
||||||
import { combineCommandSequences } from "./shared/combineCommandSequences"
|
import { combineCommandSequences, COMMAND_STDIN_STRING } from "./shared/combineCommandSequences"
|
||||||
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "./shared/ExtensionMessage"
|
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "./shared/ExtensionMessage"
|
||||||
import { getApiMetrics } from "./shared/getApiMetrics"
|
import { getApiMetrics } from "./shared/getApiMetrics"
|
||||||
import { HistoryItem } from "./shared/HistoryItem"
|
import { HistoryItem } from "./shared/HistoryItem"
|
||||||
@@ -27,9 +27,10 @@ import { findLast, findLastIndex } from "./utils"
|
|||||||
import { truncateHalfConversation } from "./utils/context-management"
|
import { truncateHalfConversation } from "./utils/context-management"
|
||||||
import { regexSearchFiles } from "./utils/ripgrep"
|
import { regexSearchFiles } from "./utils/ripgrep"
|
||||||
import { extractTextFromFile } from "./utils/extract-text"
|
import { extractTextFromFile } from "./utils/extract-text"
|
||||||
|
import { getPythonEnvPath } from "./utils/get-python-env"
|
||||||
|
|
||||||
const SYSTEM_PROMPT =
|
const SYSTEM_PROMPT =
|
||||||
() => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
|
async () => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
|
||||||
|
|
||||||
====
|
====
|
||||||
|
|
||||||
@@ -84,7 +85,17 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
|
|||||||
SYSTEM INFORMATION
|
SYSTEM INFORMATION
|
||||||
|
|
||||||
Operating System: ${osName()}
|
Operating System: ${osName()}
|
||||||
Default Shell: ${defaultShell}
|
Default Shell: ${defaultShell}${await (async () => {
|
||||||
|
try {
|
||||||
|
const pythonEnvPath = await getPythonEnvPath()
|
||||||
|
if (pythonEnvPath) {
|
||||||
|
return `\nPython Environment: ${pythonEnvPath}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Failed to get python env path", error)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})()}
|
||||||
Home Directory: ${os.homedir()}
|
Home Directory: ${os.homedir()}
|
||||||
Current Working Directory: ${cwd}
|
Current Working Directory: ${cwd}
|
||||||
`
|
`
|
||||||
@@ -1193,9 +1204,11 @@ export class ClaudeDev {
|
|||||||
return "The user denied this operation."
|
return "The user denied this operation."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let userFeedback: { text?: string; images?: string[] } | undefined
|
||||||
const sendCommandOutput = async (subprocess: ResultPromise, line: string): Promise<void> => {
|
const sendCommandOutput = async (subprocess: ResultPromise, line: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { response, text } = await this.ask("command_output", line)
|
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 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") {
|
if (response === "yesButtonTapped") {
|
||||||
// SIGINT is typically what's sent when a user interrupts a process (like pressing Ctrl+C)
|
// SIGINT is typically what's sent when a user interrupts a process (like pressing Ctrl+C)
|
||||||
@@ -1209,12 +1222,27 @@ export class ClaudeDev {
|
|||||||
treeKill(subprocess.pid, "SIGINT")
|
treeKill(subprocess.pid, "SIGINT")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (isStdin) {
|
||||||
|
const stdin = text?.slice(COMMAND_STDIN_STRING.length) ?? ""
|
||||||
|
|
||||||
|
// 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
|
// if the user sent some input, we send it to the command stdin
|
||||||
// add newline as cli programs expect a newline after each input
|
// 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))
|
// (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(text + "\n")
|
subprocess.stdin?.write(stdin + "\n")
|
||||||
// Recurse with an empty string to continue listening for more input
|
// 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
|
sendCommandOutput(subprocess, "") // empty strings are effectively ignored by the webview, this is done solely to relinquish control over the exit command button
|
||||||
|
} else {
|
||||||
|
userFeedback = { text, images }
|
||||||
|
if (subprocess.pid) {
|
||||||
|
treeKill(subprocess.pid, "SIGINT")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// This can only happen if this ask promise was ignored, so ignore this error
|
// This can only happen if this ask promise was ignored, so ignore this error
|
||||||
@@ -1262,6 +1290,17 @@ export class ClaudeDev {
|
|||||||
// grouping command_output messages despite any gaps anyways)
|
// grouping command_output messages despite any gaps anyways)
|
||||||
await delay(100)
|
await delay(100)
|
||||||
this.executeCommandRunningProcess = undefined
|
this.executeCommandRunningProcess = undefined
|
||||||
|
|
||||||
|
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<feedback>\n${
|
||||||
|
userFeedback.text
|
||||||
|
}\n</feedback>\n\n${await this.getPotentiallyRelevantDetails()}`,
|
||||||
|
userFeedback.images
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// for attemptCompletion, we don't want to return the command output
|
// for attemptCompletion, we don't want to return the command output
|
||||||
if (returnEmptyStringOnSuccess) {
|
if (returnEmptyStringOnSuccess) {
|
||||||
return ""
|
return ""
|
||||||
@@ -1323,7 +1362,7 @@ export class ClaudeDev {
|
|||||||
|
|
||||||
async attemptApiRequest(): Promise<Anthropic.Messages.Message> {
|
async attemptApiRequest(): Promise<Anthropic.Messages.Message> {
|
||||||
try {
|
try {
|
||||||
let systemPrompt = SYSTEM_PROMPT()
|
let systemPrompt = await SYSTEM_PROMPT()
|
||||||
if (this.customInstructions && this.customInstructions.trim()) {
|
if (this.customInstructions && this.customInstructions.trim()) {
|
||||||
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
|
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
|
||||||
systemPrompt += `
|
systemPrompt += `
|
||||||
|
|||||||
@@ -71,3 +71,4 @@ export function combineCommandSequences(messages: ClaudeMessage[]): ClaudeMessag
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
export const COMMAND_OUTPUT_STRING = "Output:"
|
export const COMMAND_OUTPUT_STRING = "Output:"
|
||||||
|
export const COMMAND_STDIN_STRING = "Input:"
|
||||||
|
|||||||
34
src/utils/get-python-env.ts
Normal file
34
src/utils/get-python-env.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import * as vscode from "vscode"
|
||||||
|
|
||||||
|
export async function getPythonEnvPath(): Promise<string | undefined> {
|
||||||
|
const pythonExtension = vscode.extensions.getExtension("ms-python.python")
|
||||||
|
|
||||||
|
if (!pythonExtension) {
|
||||||
|
console.log("Python extension is not installed.")
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the Python extension is activated
|
||||||
|
if (!pythonExtension.isActive) {
|
||||||
|
// if the python extension is not active, we can assume the project is not a python project
|
||||||
|
console.log("Python extension is not active.")
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access the Python extension API
|
||||||
|
const pythonApi = pythonExtension.exports
|
||||||
|
// Get the active environment path for the current workspace
|
||||||
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
|
||||||
|
if (!workspaceFolder) {
|
||||||
|
console.log("No workspace folder is open.")
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// Get the active python environment path for the current workspace
|
||||||
|
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(workspaceFolder.uri)
|
||||||
|
console.log("Python environment path:", pythonEnv)
|
||||||
|
if (pythonEnv && pythonEnv.path) {
|
||||||
|
return pythonEnv.path
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequenc
|
|||||||
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
||||||
import CodeBlock from "./CodeBlock"
|
import CodeBlock from "./CodeBlock"
|
||||||
import Thumbnails from "./Thumbnails"
|
import Thumbnails from "./Thumbnails"
|
||||||
import { ApiProvider } from "../../../src/shared/api"
|
import Terminal from "./Terminal"
|
||||||
|
|
||||||
interface ChatRowProps {
|
interface ChatRowProps {
|
||||||
message: ClaudeMessage
|
message: ClaudeMessage
|
||||||
@@ -16,7 +16,7 @@ interface ChatRowProps {
|
|||||||
onToggleExpand: () => void
|
onToggleExpand: () => void
|
||||||
lastModifiedMessage?: ClaudeMessage
|
lastModifiedMessage?: ClaudeMessage
|
||||||
isLast: boolean
|
isLast: boolean
|
||||||
apiProvider?: ApiProvider
|
handleSendStdin: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatRow: React.FC<ChatRowProps> = ({
|
const ChatRow: React.FC<ChatRowProps> = ({
|
||||||
@@ -26,7 +26,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
|||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
lastModifiedMessage,
|
lastModifiedMessage,
|
||||||
isLast,
|
isLast,
|
||||||
apiProvider,
|
handleSendStdin,
|
||||||
}) => {
|
}) => {
|
||||||
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined
|
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined
|
||||||
const apiRequestFailedMessage =
|
const apiRequestFailedMessage =
|
||||||
@@ -411,7 +411,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
command: text.slice(0, outputIndex).trim(),
|
command: text.slice(0, outputIndex).trim(),
|
||||||
output: text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim(),
|
output: text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trimStart(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,32 +422,10 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
|||||||
{icon}
|
{icon}
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Terminal
|
||||||
<div>
|
output={command + (output ? "\n" + output : "")}
|
||||||
<CodeBlock
|
handleSendStdin={handleSendStdin}
|
||||||
code={command}
|
|
||||||
language="shell-session"
|
|
||||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
onToggleExpand={onToggleExpand}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{output && (
|
|
||||||
<>
|
|
||||||
<p style={{ ...pStyle, margin: "10px 0 10px 0" }}>
|
|
||||||
{COMMAND_OUTPUT_STRING}
|
|
||||||
</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={output}
|
|
||||||
language="shell-session"
|
|
||||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
onToggleExpand={onToggleExpand}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
case "completion_result":
|
case "completion_result":
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useEvent, useMount } from "react-use"
|
|||||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||||
import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
|
import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
|
||||||
import { combineApiRequests } from "../../../src/shared/combineApiRequests"
|
import { combineApiRequests } from "../../../src/shared/combineApiRequests"
|
||||||
import { combineCommandSequences } from "../../../src/shared/combineCommandSequences"
|
import { combineCommandSequences, COMMAND_STDIN_STRING } from "../../../src/shared/combineCommandSequences"
|
||||||
import { getApiMetrics } from "../../../src/shared/getApiMetrics"
|
import { getApiMetrics } from "../../../src/shared/getApiMetrics"
|
||||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||||
import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
||||||
@@ -244,6 +244,20 @@ const ChatView = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSendStdin = (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
This logic depends on the useEffect[messages] above to set claudeAsk, after which buttons are shown and we then send an askResponse to the extension.
|
This logic depends on the useEffect[messages] above to set claudeAsk, after which buttons are shown and we then send an askResponse to the extension.
|
||||||
*/
|
*/
|
||||||
@@ -442,19 +456,13 @@ const ChatView = ({
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [visibleMessages])
|
}, [visibleMessages])
|
||||||
|
|
||||||
const [placeholderText, isInputPipingToStdin] = useMemo(() => {
|
const placeholderText = useMemo(() => {
|
||||||
if (messages.at(-1)?.ask === "command_output") {
|
|
||||||
return ["Type input to command stdin...", true]
|
|
||||||
}
|
|
||||||
const text = task ? "Type a message..." : "Type your task here..."
|
const text = task ? "Type a message..." : "Type your task here..."
|
||||||
return [text, false]
|
return text
|
||||||
}, [task, messages])
|
}, [task])
|
||||||
|
|
||||||
const shouldDisableImages =
|
const shouldDisableImages =
|
||||||
!selectedModelSupportsImages ||
|
!selectedModelSupportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
|
||||||
textAreaDisabled ||
|
|
||||||
selectedImages.length >= MAX_IMAGES_PER_MESSAGE ||
|
|
||||||
isInputPipingToStdin
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -536,7 +544,7 @@ const ChatView = ({
|
|||||||
onToggleExpand={() => toggleRowExpansion(message.ts)}
|
onToggleExpand={() => toggleRowExpansion(message.ts)}
|
||||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||||
isLast={index === visibleMessages.length - 1}
|
isLast={index === visibleMessages.length - 1}
|
||||||
apiProvider={apiConfiguration?.apiProvider}
|
handleSendStdin={handleSendStdin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
303
webview-ui/src/components/Terminal.tsx
Normal file
303
webview-ui/src/components/Terminal.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react"
|
||||||
|
import DynamicTextArea from "react-textarea-autosize"
|
||||||
|
|
||||||
|
interface TerminalProps {
|
||||||
|
output: string
|
||||||
|
handleSendStdin: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Inspired by https://phuoc.ng/collection/mirror-a-text-area/create-your-own-custom-cursor-in-a-text-area/
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Terminal: React.FC<TerminalProps> = ({ output, handleSendStdin }) => {
|
||||||
|
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("")
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
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.clientWidth + 2 * borderWidth}px`
|
||||||
|
mirror.style.height = `${newHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSize()
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateSize)
|
||||||
|
resizeObserver.observe(textarea)
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
caretEle.innerHTML = " "
|
||||||
|
mirror.appendChild(caretEle)
|
||||||
|
}
|
||||||
|
}, [output, userInput, isFocused])
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value
|
||||||
|
if (newValue.startsWith(output)) {
|
||||||
|
setUserInput(newValue.slice(output.length))
|
||||||
|
} else {
|
||||||
|
setUserInput(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger resize after setting user input
|
||||||
|
const textarea = textAreaRef.current
|
||||||
|
const hiddenTextarea = hiddenTextareaRef.current
|
||||||
|
if (textarea && hiddenTextarea) {
|
||||||
|
hiddenTextarea.value = newValue
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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-terminal-border)",
|
||||||
|
outline: "none",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
overflowX: "hidden",
|
||||||
|
overflowY: "hidden",
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
resize: "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="terminal-container">
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.terminal-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-terminalCursor-foreground);
|
||||||
|
position: absolute;
|
||||||
|
width: 4px;
|
||||||
|
margin-top: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-cursor-focused {
|
||||||
|
background-color: var(--vscode-terminalCursor-foreground);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<DynamicTextArea
|
||||||
|
ref={textAreaRef}
|
||||||
|
value={output + userInput}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
className="terminal-textarea"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--vscode-terminal-background)",
|
||||||
|
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,
|
||||||
|
opacity: 0,
|
||||||
|
...(textAreaStyle as any),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Terminal
|
||||||
Reference in New Issue
Block a user