mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Improve command execution UI (#3509)
This commit is contained in:
parent
d4fa359be8
commit
6df5f1d0b0
3 changed files with 107 additions and 77 deletions
|
|
@ -980,13 +980,12 @@ export const ChatRowContent = ({
|
|||
)
|
||||
case "command":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<CommandExecution executionId={message.ts.toString()} text={message.text} />
|
||||
</>
|
||||
<CommandExecution
|
||||
executionId={message.ts.toString()}
|
||||
text={message.text}
|
||||
icon={icon}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
case "use_mcp_server":
|
||||
const useMcpServer = safeJsonParse<ClineAskUseMcpServer>(message.text)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState, memo } from "react"
|
||||
import { useCallback, useState, memo, useMemo } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { ChevronDown, Skull } from "lucide-react"
|
||||
|
||||
|
|
@ -16,32 +16,25 @@ import CodeBlock from "../common/CodeBlock"
|
|||
interface CommandExecutionProps {
|
||||
executionId: string
|
||||
text?: string
|
||||
icon?: JSX.Element | null
|
||||
title?: JSX.Element | null
|
||||
}
|
||||
|
||||
const parseCommandAndOutput = (text: string) => {
|
||||
const index = text.indexOf(COMMAND_OUTPUT_STRING)
|
||||
if (index === -1) {
|
||||
return { command: text, output: "" }
|
||||
}
|
||||
return {
|
||||
command: text.slice(0, index),
|
||||
output: text.slice(index + COMMAND_OUTPUT_STRING.length),
|
||||
}
|
||||
}
|
||||
|
||||
export const CommandExecution = ({ executionId, text }: CommandExecutionProps) => {
|
||||
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
|
||||
const { terminalShellIntegrationDisabled = false } = useExtensionState()
|
||||
|
||||
// If we aren't opening the VSCode terminal for this command then we default
|
||||
// to expanding the command execution output.
|
||||
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
|
||||
|
||||
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
|
||||
const { command: initialCommand, output: initialOutput } = text
|
||||
? parseCommandAndOutput(text)
|
||||
: { command: "", output: "" }
|
||||
const { command: initialCommand, output: initialOutput } = useMemo(
|
||||
() => (text ? parseCommandAndOutput(text) : { command: "", output: "" }),
|
||||
[text],
|
||||
)
|
||||
|
||||
const [output, setOutput] = useState(initialOutput)
|
||||
const [command, setCommand] = useState(initialCommand)
|
||||
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
|
|
@ -81,62 +74,84 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
|
|||
useEvent("message", onMessage)
|
||||
|
||||
return (
|
||||
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
|
||||
<CodeBlock source={text ? parseCommandAndOutput(text).command : command} language="shell" />
|
||||
<div className="flex flex-row items-center justify-between gap-2 px-1">
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-between gap-2 mb-1">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{status?.status === "started" && (
|
||||
<div className="flex flex-row items-center gap-2 font-mono text-xs">
|
||||
<div className="rounded-full size-1.5 bg-lime-400" />
|
||||
<div>Running</div>
|
||||
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
|
||||
}>
|
||||
<Skull />
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between gap-2 px-1">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{status?.status === "started" && (
|
||||
<div className="flex flex-row items-center gap-2 font-mono text-xs">
|
||||
<div className="rounded-full size-1.5 bg-lime-400" />
|
||||
<div>Running</div>
|
||||
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
|
||||
}>
|
||||
<Skull />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status?.status === "exited" && (
|
||||
<div className="flex flex-row items-center gap-2 font-mono text-xs">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full size-1.5",
|
||||
status.exitCode === 0 ? "bg-lime-400" : "bg-red-400",
|
||||
)}
|
||||
/>
|
||||
<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
|
||||
</div>
|
||||
)}
|
||||
{output.length > 0 && (
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform duration-300", {
|
||||
"rotate-180": isExpanded,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status?.status === "exited" && (
|
||||
<div className="flex flex-row items-center gap-2 font-mono text-xs">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full size-1.5",
|
||||
status.exitCode === 0 ? "bg-lime-400" : "bg-red-400",
|
||||
)}
|
||||
/>
|
||||
<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
|
||||
</div>
|
||||
)}
|
||||
{output.length > 0 && (
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform duration-300", {
|
||||
"rotate-180": isExpanded,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MemoizedOutputContainer isExpanded={isExpanded} output={output} />
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
|
||||
<CodeBlock source={command} language="shell" />
|
||||
<OutputContainer isExpanded={isExpanded} output={output} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
CommandExecution.displayName = "CommandExecution"
|
||||
|
||||
const OutputContainer = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
|
||||
const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
|
||||
<div
|
||||
className={cn("mt-1 pt-1 border-t border-border/25 overflow-hidden transition-[max-height] duration-300", {
|
||||
className={cn("overflow-hidden", {
|
||||
"max-h-0": !isExpanded,
|
||||
"max-h-[100%]": isExpanded,
|
||||
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
|
||||
})}>
|
||||
{output.length > 0 && <CodeBlock source={output} language="log" />}
|
||||
</div>
|
||||
)
|
||||
|
||||
const MemoizedOutputContainer = memo(OutputContainer)
|
||||
const OutputContainer = memo(OutputContainerInternal)
|
||||
|
||||
const parseCommandAndOutput = (text: string) => {
|
||||
const index = text.indexOf(COMMAND_OUTPUT_STRING)
|
||||
|
||||
if (index === -1) {
|
||||
return { command: text, output: "" }
|
||||
}
|
||||
|
||||
return {
|
||||
command: text.slice(0, index),
|
||||
output: text.slice(index + COMMAND_OUTPUT_STRING.length),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { bundledLanguages } from "shiki"
|
|||
import type { ShikiTransformer } from "shiki"
|
||||
import { ChevronDown, ChevronUp, WrapText, AlignJustify, Copy, Check } from "lucide-react"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
|
||||
export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
|
||||
export const WRAPPER_ALPHA = "cc" // 80% opacity
|
||||
|
||||
// Configuration constants
|
||||
export const WINDOW_SHADE_SETTINGS = {
|
||||
transitionDelayS: 0.2,
|
||||
|
|
@ -95,7 +97,6 @@ const CodeBlockButtonWrapper = styled.div`
|
|||
const CodeBlockContainer = styled.div`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 4px solid var(--vscode-sideBar-background);
|
||||
background-color: ${CODE_BLOCK_BG_COLOR};
|
||||
|
||||
${CodeBlockButtonWrapper} {
|
||||
|
|
@ -122,7 +123,6 @@ export const StyledPre = styled.div<{
|
|||
windowshade === "true" ? `${collapsedHeight || WINDOW_SHADE_SETTINGS.collapsedHeight}px` : "none"};
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
// transition: max-height ${WINDOW_SHADE_SETTINGS.transitionDelayS} ease-out;
|
||||
border-radius: 5px;
|
||||
${({ preStyle }) => preStyle && { ...preStyle }}
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ export const StyledPre = styled.div<{
|
|||
|
||||
pre,
|
||||
code {
|
||||
/* Undefined wordwrap defaults to true (pre-wrap) behavior */
|
||||
/* Undefined wordwrap defaults to true (pre-wrap) behavior. */
|
||||
white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")};
|
||||
word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")};
|
||||
overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")};
|
||||
|
|
@ -233,24 +233,28 @@ const CodeBlock = memo(
|
|||
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
// Update current language when prop changes, but only if user hasn't made a selection
|
||||
// Update current language when prop changes, but only if user hasn't
|
||||
// made a selection.
|
||||
useEffect(() => {
|
||||
const normalizedLang = normalizeLanguage(language)
|
||||
|
||||
if (normalizedLang !== currentLanguage && !userChangedLanguageRef.current) {
|
||||
setCurrentLanguage(normalizedLang)
|
||||
}
|
||||
}, [language, currentLanguage])
|
||||
|
||||
// Syntax highlighting with cached Shiki instance
|
||||
// Syntax highlighting with cached Shiki instance.
|
||||
useEffect(() => {
|
||||
const fallback = `<pre style="padding: 0; margin: 0;"><code class="hljs language-${currentLanguage || "txt"}">${source || ""}</code></pre>`
|
||||
|
||||
const highlight = async () => {
|
||||
// Show plain text if language needs to be loaded
|
||||
// Show plain text if language needs to be loaded.
|
||||
if (currentLanguage && !isLanguageLoaded(currentLanguage)) {
|
||||
setHighlightedCode(fallback)
|
||||
}
|
||||
|
||||
const highlighter = await getHighlighter(currentLanguage)
|
||||
|
||||
const html = await highlighter.codeToHtml(source || "", {
|
||||
lang: currentLanguage || "txt",
|
||||
theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark",
|
||||
|
|
@ -273,6 +277,7 @@ const CodeBlock = memo(
|
|||
},
|
||||
] as ShikiTransformer[],
|
||||
})
|
||||
|
||||
setHighlightedCode(html)
|
||||
}
|
||||
|
||||
|
|
@ -285,13 +290,15 @@ const CodeBlock = memo(
|
|||
// Check if content height exceeds collapsed height whenever content changes
|
||||
useEffect(() => {
|
||||
const codeBlock = codeBlockRef.current
|
||||
|
||||
if (codeBlock) {
|
||||
const actualHeight = codeBlock.scrollHeight
|
||||
setShowCollapseButton(actualHeight >= WINDOW_SHADE_SETTINGS.collapsedHeight)
|
||||
}
|
||||
}, [highlightedCode])
|
||||
|
||||
// Ref to track if user was scrolled up *before* the source update potentially changes scrollHeight
|
||||
// Ref to track if user was scrolled up *before* the source update
|
||||
// potentially changes scrollHeight
|
||||
const wasScrolledUpRef = useRef(false)
|
||||
|
||||
// Ref to track if outer container was near bottom
|
||||
|
|
@ -331,13 +338,14 @@ const CodeBlock = memo(
|
|||
}
|
||||
|
||||
scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true })
|
||||
|
||||
// Initial check
|
||||
handleOuterScroll()
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", handleOuterScroll)
|
||||
}
|
||||
}, []) // Empty dependency array: runs once on mount
|
||||
}, [])
|
||||
|
||||
// Store whether we should scroll after highlighting completes
|
||||
const shouldScrollAfterHighlightRef = useRef(false)
|
||||
|
|
@ -355,16 +363,24 @@ const CodeBlock = memo(
|
|||
const updateCodeBlockButtonPosition = useCallback((forceHide = false) => {
|
||||
const codeBlock = codeBlockRef.current
|
||||
const copyWrapper = copyButtonWrapperRef.current
|
||||
if (!codeBlock) return
|
||||
|
||||
if (!codeBlock) {
|
||||
return
|
||||
}
|
||||
|
||||
const rectCodeBlock = codeBlock.getBoundingClientRect()
|
||||
const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
|
||||
if (!scrollContainer) return
|
||||
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get wrapper height dynamically
|
||||
let wrapperHeight
|
||||
|
||||
if (copyWrapper) {
|
||||
const copyRect = copyWrapper.getBoundingClientRect()
|
||||
|
||||
// If height is 0 due to styling, estimate from children
|
||||
if (copyRect.height > 0) {
|
||||
wrapperHeight = copyRect.height
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue